# Flow — public front door > Last verified: 2026-08-02 against commit `c841bde` **Actor(s):** guest (unauthenticated) · **Status:** partial — what is built works; tier (c) guest browse is not built **Client:** real (rewrite, gating and `?next=` all verified against a production build) · **Server:** real **Business source:** no dedicated business area — the depth decision lives in [product/notes/open-questions.md § "Decided — public guest-browse depth"](../../product/notes/open-questions.md); the nearest requirement is "a customer can register and browse with only a verified phone" in [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md) **Integration:** [docs/integration/api-contract.md § The anonymous surface](../integration/api-contract.md) · [domains/search.md](../integration/domains/search.md) ## What it does A visitor with no session lands on `/` and is meant to see a marketing landing — what Balinyaar is, the service categories, how escrow works, why nurses are trusted — plus a nurse-recruitment CTA and the legal pages. Everything else redirects to login, carrying the attempted URL so a deep link survives the round trip. Guest **browse** (searching nurses, opening a public profile without logging in) was deliberately **not** built: tier (c) is still deferred. ## Screens | Step | Route | Component / notes | | --- | --- | --- | | 1 | `/fa` (guest) | Middleware **rewrites** — never redirects — to `/{locale}/welcome`, so the URL and SEO canonical stay `/` ([`client/middleware.ts:35-37`](../../client/middleware.ts)) | | 1′ | `/fa` (signed in) | Falls through to the customer Home; a **nurse/admin** hitting `/` is a `RoleGuard` mismatch and is bounced to their own app | | 2 | `/fa/welcome` | `(public-routes)/welcome/WelcomeScreen.tsx` — RSC with **zero query hooks**; hero, 5 static category tiles (linking to login, not to search), 3-step "how it works" with the verbatim `EscrowNotice`, 4-row trust explainer, nurse CTA (`/login?role=nurse`), footer. A signed-in hit here 307s to `/` (`middleware.ts:40-42`) | | 3 | `/fa/terms`, `/fa/privacy` | Static RSC legal copy, 8 / 7 sections, both fronted by a «پیشنویس» draft banner — **not legally reviewed**. Linked from the welcome footer and the login consent line | | 4 | `/fa/login` | Phone-OTP entry — see the auth flow, not this file | | 5 | any unmatched `/fa/*` | `[...rest]/page.tsx` calls `notFound()` → branded `[locale]/not-found.tsx` («صفحه پیدا نشد») | | — | render throw | `[locale]/error.tsx` («مشکلی پیش آمد») inside the locale providers; `app/global-error.tsx` above it | | — | `/robots.txt`, `/sitemap.xml` | `app/robots.ts` disallows 13 private roots per locale; `app/sitemap.ts` emits only `''`, `/login`, `/terms`, `/privacy` × 2 locales | `PUBLIC_PATHS = ['/login','/terms','/privacy','/welcome']` ([`src/constants/routes.ts:210`](../../client/src/constants/routes.ts)), matched with `startsWith`. **`ROUTES.HOME` ('/') must never be added** — `startsWith('/')` would un-gate every route. That trap is why the guest root is an exact-match rewrite instead of a `PUBLIC_PATHS` entry. ## API The front door itself calls **nothing** — welcome, terms and privacy are static Server Components. The 20 anonymous server operations exist for other flows (and for the unbuilt tier c). Shapes: [api-contract.md](../integration/api-contract.md). | Call | Endpoint | Notes | | --- | --- | --- | | OTP pair | `POST /auth/request_otp`, `POST /auth/verify_otp` | the only anonymous ops the front door actually reaches, via `/login` | | Reference reads | `GET /catalog/categories`, `/catalog/option_groups`, `/geo/{provinces,cities,districts,tree}` | probed **without a token → 200**. The welcome grid does **not** use them (static i18n labels) | | Public nurse reads | `GET /nurses/{id}/profile`, `/trust_badge`, `/reviews`, `/review_tags`, `/nurse_variants/get/{id}` | anonymous **today**, but no guest UI consumes them | | Search | `GET /search/nurses` | anonymous; **requires** `cityId` and `serviceCategoryId` > 0 — bare call returns `400`, so there is no open browse | | Ping | `GET /ping/get_status`, `/get_status_rate_limited` | anonymous liveness | | Webhooks | `POST /webhooks/payments/{p}`, `/webhooks_bnpl/{p}`, `/webhooks/payouts/{p}` | provider callbacks, not a UI surface | | Dev | `GET /dev/last_otp/{phone}` | Development-only, anonymous — see [testing-setup.md](testing-setup.md) | ## Rules that must hold | Rule | Source | | --- | --- | | `/` **forks by auth with a rewrite, never a redirect** — the guest URL and canonical stay `/` | [`middleware.ts:31-37`](../../client/middleware.ts), welcome `generateMetadata` sets `alternates.canonical = '/{locale}'` | | `'/'` is never in `PUBLIC_PATHS` (the `startsWith` trap) | [`routes.ts:205-210`](../../client/src/constants/routes.ts), client/CLAUDE.md hard rule 16 | | The middleware auth check is **UX only** — `isTokenAlive` cannot read a JWE claim, so any well-formed 5-part token counts as alive. Never a security boundary | client/CLAUDE.md hard rule 17 | | `?next=` is validated same-origin **and** role-permitting on the way out (`resolvePostLoginDestination`); `/partner*` is never a valid target | [`services/auth/routing.ts`](../../client/src/services/auth/routing.ts) | | The escrow line on the landing is **product-mandated verbatim fa copy**, never paraphrased | `EscrowNotice.tsx:8-9` | | Guest browse depth = tiers (a)+(b) only; tier (c) is deferred pending REQ-066/067 + a privacy sign-off | [open-questions.md §3.1](../../product/notes/open-questions.md) | | Welcome must never wait on an API call — RSC, zero query hooks in the whole tree | `WelcomeScreen.tsx:22-30` | **Tier (c) is still deferred — confirmed.** REQ-066/067 remain open, but *narrower than filed*: both endpoints are **already anonymous** on the server (probed below). What is missing is (a) the rate limit — `SearchController`/`NursesController` carry no `[EnableRateLimiting]`, so guest traffic falls to the 100/min global per-IP limiter — and (b) the privacy review of the profile payload. See [domains/search.md:80-81](../integration/domains/search.md). No guest search/profile route exists in the client. ## How to test 1. Log in as nobody — this flow is the logged-out surface. Boot per [testing-setup.md](testing-setup.md); use a **private window** (or clear `access_token`/`refresh_token`). 2. **Prove the anonymous surface, no token.** All seven returned `200`: ```bash for u in nurses/1/profile nurses/1/trust_badge "nurses/1/reviews?page=1&pageSize=2" \ catalog/categories geo/provinces ping/get_status nurses/3/profile; do curl -s --noproxy '*' -o /dev/null -w "$u %{http_code}\n" "http://localhost:5002/api/v1/$u"; done ``` **Expect:** seven `200`s. `nurses/1/trust_badge` → `isVerified: true`, `credentialTypes: ["criminal_record","moh_competency_license"]`; `nurses/1/reviews` → `averageRating: 5.00, publishedCount: 1`. A bare `GET /search/nurses` → **`400`** («City Id must be greater than 0») — that is correct, not a defect. 3. Open `http://localhost:3000/fa/welcome`. **Expect:** the marketing landing — brand lockup + tagline, «شروع کنید» CTA, 5 category tiles, «چطور کار میکند» with the escrow callout, the trust card, the nurse CTA, and terms/privacy footer links. Tab title is plain «بالینیار» (welcome sets no `title`). 4. Open `/fa`. **Expect:** the *same* body as step 3 with the URL still `/fa` — the rewrite, not a redirect. 5. Open `/fa/terms` and `/fa/privacy`. **Expect:** 8 and 7 numbered sections, each under the blue «این متن پیشنویس است…» draft banner. 6. Open `/fa/zzz-nope` **logged out**. **Expect:** `307 → /fa/login?next=%2Fzzz-nope` — the auth gate runs before routing, so a guest never reaches the branded «صفحه پیدا نشد» card. Log in first to see it. 7. Open `/fa/bookings` logged out. **Expect:** `307 → /fa/login?next=%2Fbookings`. 8. `curl /robots.txt` and `/sitemap.xml`. **Expect:** `200`; robots disallows `/*/bookings`, `/*/nurse`, `/*/admin`, … ; the sitemap lists exactly 4 URLs × 2 hreflang alternates. > **Run these against a production build, not `next dev`.** `cd client && npm run build && PORT=3001 npm run start`. > Under `next dev` bare `/` returns `404` (a Turbopack root-path quirk) and stale `.next/` prerenders can be > served ahead of the middleware, which makes every gate above look broken when it is not. **Verified 2026-08-02 against `npm run build` (exit 0) on `:3001`:** `/` → `307 → /fa` · guest `/fa` → `200` and byte-identical to `/fa/welcome` bar the URL (213 894 vs 213 906 chars) · `/fa/{bookings,profile,wallet, admin,nurse,select-role,zzz-nope}` → `307 → /fa/login?next=…` each · `/bookings` → `307 → /fa/bookings` · `/robots.txt` and `/sitemap.xml` → `200`. ## Known gaps - **`next dev` cannot be used to check any of this — and it looks broken when it is not.** On the dev server every gate above appeared to fail: `/fa` served the customer Home's metadata (`