Skip to content

01a03535 - Add invite and promo landing pages - #30

Merged
TaprootFreak merged 32 commits into
RealUnitCH:developfrom
JonnyLuca:01a03535-referral-promo-2
Sep 9, 2026
Merged

01a03535 - Add invite and promo landing pages#30
TaprootFreak merged 32 commits into
RealUnitCH:developfrom
JonnyLuca:01a03535-referral-promo-2

Conversation

@JonnyLuca

@JonnyLuca JonnyLuca commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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).
  • Public lookup GET /v1/realunit/referral/code/:code consumes backend inviterName / inviteeName (CORS on realunit.app). NestJS unmounted Cannot GET is unavailable, not invalid.
  • Universal Links / App Links: AASA + Digital Asset Links as HTTP 200 on apex and www (no 301 on .well-known/). Extra AASA alias /apple-app-site-association as HTTP 200 (www included).
  • Referral code reflected into the crawler HTML is escaped at the text sinks with a function replacer; the production Digital Asset Links no longer carry the Android debug-keystore fingerprint.
  • Store badges, copy code/link, iOS re-tap hint, Android Play Install Referrer (attached during lookup).
  • Playwright tests/site.spec.mjs + tests/behavior.spec.mjs + tests/visual.spec.mjs. Visual matrix in tests/pages.mjs (invite/promo × ok/loading/invalid/unavailable/spent/missing-code/fallback × DE/EN × desktop/tablet/iOS/Android as applicable). Baselines via e2e:docker:update only.

Measured (native host; content identical to the reviewed #27 head)

Check Result
npm run check Prettier OK; html-validate OK; check-site 6 HTML / 54 refs; vitest 144/144; 100% lines/statements/branches/functions
npm run e2e:docker 234 passed, 282 project-inapplicable skipped, 0 failed; check-visual 112/112 baselines present

Post-merge / operator actions

  • Backend rollout: DFXswiss/backend#5415 implements and tests the matching lookup route and fields; live deployment remains human-owned.
  • Human: merge this PR so production realunit.app AASA, assetlinks and /invite/* stop 404ing.
  • Human: Play App Signing fingerprint if it differs from the GitHub APK.

Related: RealUnitCH/intern#3 (do not auto-close).

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.
@JonnyLuca

Copy link
Copy Markdown
Contributor Author

Finalisation (double-review + CI green)

This branch supersedes #27 with clean, signed, tool-neutral commits and adds the review hardening on top.

  • Cross-vendor double-review (Claude Opus 5 + Codex, conformity + logic) run to 0 findings: the crawler-HTML sinks escape a reflected referral code at every text sink (og/twitter/<title>, image-alt, description) with a function replacer so a $ in the code is never a backreference; a plain-text Cannot GET 404 maps to unavailable; a 2xx with a non-JSON body now also falls through to unavailable instead of a false success; the production Digital Asset Links no longer carry the Android debug fingerprint.
  • CI green on this head: Quality (prettier + html-validate + check-site + 144/144 unit tests, 100% coverage) and Visual regression (265 Playwright tests, check-visual OK) both pass. Locally reproduced via npm run check and npm run e2e:docker.
  • mergeable = MERGEABLE, no open review threads.

Ready for human review and merge.

TaprootFreak pushed a commit to RealUnitCH/app that referenced this pull request Sep 8, 2026
## 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.
@TaprootFreakAI

Copy link
Copy Markdown
Collaborator

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 here

Wrong referrer could be credited

  • unwrapNestedCode matched invite/ anywhere in the value, so an issued code containing it (PREFIXINVITE/AB12CD) collapsed onto its tail. Codes may legitimately contain a slash — tests/behavior.spec.mjs covers /invite/AB%2F12 — and there is no character validation, so this hit honest users, not just crafted links. Anchored at start-or-slash on both the browser module and the function mirror.
  • codeFromReferralHostInPath accepted our host from any segment of a foreign path: example.com/r/realunit.app/invite/ATTACKER yielded ATTACKER, silently defeating the isRealUnitHost check twenty lines above it. Restricted to the marked viewer forms that actually need it (/amp/s/<host>, /c/s/<host>, /https/<host>); AMP and android-app links keep working.
  • The function-side landing matcher was unbounded, so evilrealunit.app/invite/X contained our host as a substring and passed. Bounded on the left.

Wrong programme handed to the app

  • The page switched its copy on the API-confirmed kind but every hand-off kept the kind from the URL path. /invite/<a promo code> therefore showed the promo text while the deeplink, the smart app banner and the Play install referrer still said invite=. A single effective kind is now re-applied once the lookup answers.

Crash and silent mangling in the cap

  • Capping ran after the trailing-punctuation strip, so a cut landing on a separator left it in place (…C.). A cut through a surrogate pair left a lone surrogate, which makes encodeURIComponent throw when the code goes into the lookup URL. Both mirrored client and server.
  • The 32-character cap itself was deliberately not changed to a rejection: sanitizeReferralCode in the API and the app fold identically, and diverging here would be worse than the collision it avoids.

Two tests asserted the bugs as intended behaviour?code=prefixinvite/AB12CD → AB12CD and example.com/%zz/realunit.app/invite/AB12CD → AB12CD. Both corrected, and the attack case added as a regression test.

Test and gate quality
  • coverage.include measured the wrong file. It listed only public/js/lib, so the 100% badge was earned partly by a browser-side mirror whose two HTML transformers are referenced from tests only, while functions/lib/itunes-banner.js — the module _middleware.js actually imports — was not measured at all. It sits at 98.98% lines / 88% branches; that is now included and ratcheted, with three added tests for paths this uncovered (bare store link, unparseable lang, fragment-only code).
  • Four assertions did not test the thing under test: three parser fallbacks accepted any non-empty string (a truncated or half-decoded code would have passed), 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 received. All now assert values.
  • One branch is genuinely unreachable and is marked as such with the reason, rather than lowering the gate: every caller hands unwrapNestedCode a single already-extracted segment. The only thing that ever reached its match arm was the test that asserted the folding bug.
Documentation corrected

Three ground rules described something other than the code they govern: public/ does not ship verbatim for invite/promo (the middleware rewrites those bytes), "no external resources of any kind (…, fetch)" sat next to the API lookup that connect-src explicitly allows, and the visual matrix listed three families while VIEWS carries six across 72 views.

Verification
  • npm run check (format, HTML validation, check:site, unit tests + coverage): exit 0, 153 tests, coverage thresholds met.
  • npx playwright test behavior.spec.mjs --project=desktop-chromium -g "invite and promo landing": 52 passed.
  • Every fix above was verified by reverting it and confirming its test turns red.
  • CI at this head: Quality green (the fork gate had left the runs at action_required, so they were approved first — a green check on an older head is not evidence for this one).
Deliberately not changed — carried into the follow-up PR
  • Whether _redirects and _headers apply to Function-served requests is a Cloudflare platform question that cannot be settled from this repo, and there is no preview deployment for this PR. The middleware calls context.next() rather than building its own response and copies the response headers, which argues that both still apply; production also demonstrably serves CSP and X-Frame-Options today. Worth one curl -sI https://…/invite/AB12CD after deploy.
  • scripts/dev-server.mjs hardcodes the invite/promo rewrite, so the Playwright suite exercises a different routing mechanism than production.
  • ?lang=en without a code yields lang="en" over German title and description.
  • The iPad app-card tags are emitted but asserted nowhere; check:site checks only iphone and googleplay.
  • check:site and check:visual verify string presence and aggregate counts rather than behaviour, so a transformer that is imported but never called would pass.

@TaprootFreak
TaprootFreak merged commit c1252f9 into RealUnitCH:develop Sep 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants