# 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
  (`<title>اپلیکیشن خانواده | بالین‌یار</title>`) instead of the welcome page, `/bookings` returned `200`
  rather than `307`, `/fa/{profile,wallet,admin,nurse,select-role}` all returned `200`, and no
  `Link: …hreflang` header was emitted even though `middleware.ts:62-65` always copies one on. Every
  response carried `x-nextjs-cache: HIT` / `x-nextjs-prerender: 1` — stale prerenders being served ahead of
  the middleware. A fresh `npm run build` + `npm run start` on `:3001` reproduced **none** of it; all gates
  behave exactly as coded. The gap is in the dev-server experience, not the product: anyone auditing guest
  routing against `next dev` will file bugs that do not exist.
- `GET /api/v1/nurses/{id}/profile` returns `avatarUrl` as a **`file:///C:/Users/<user>/AppData/Local/Temp/
  balinyaar-object-storage/avatars/nurse/1/…png`** — a local filesystem path, to an **anonymous** caller.
  A browser cannot load it, and it discloses the host path. Deployed it becomes `file:///app/data/…`,
  equally unusable. Blocks REQ-067 and breaks any avatar on a public profile.
- `GET /api/v1/nurses/3/profile` returns `200` **anonymously for the unverified nurse** (مریم احمدی,
  `in_review`, `isBookable: false`) — the persona that "must never appear in search". Search correctly
  hides her; the by-id profile read does not. Enumerable by incrementing `nurseId`.
- No anonymous rate limit on the public reads: `SearchController` and `NursesController` carry no
  `[EnableRateLimiting]`, so guest traffic falls to the shared 100/min global per-IP limiter (REQ-066).
- `/terms` and `/privacy` ship **placeholder legal copy** behind a draft banner — flagged for human/legal
  review since ui-phase-3 and still unreviewed.
- Tier (c) unbuilt: there is no guest search-results screen, no public nurse profile route, and no
  guest→login handoff at the «درخواست رزرو» tap. The welcome category tiles link to `/login`, not `/search`.
- `welcome/opengraph-image.tsx` is **Latin-only** — `ImageResponse`'s fallback font has no Persian glyphs,
  so the fa OG card cannot show the Persian tagline until a Mikhak font buffer is embedded.
- `/fa/welcome` has **no in-app link** — `ROUTES.WELCOME` appears only in `middleware.ts`. It is reachable
  only as the rewrite body of `/` or by typing the URL, so the rewrite failing hides the page entirely.
- `NEXT_PUBLIC_SITE_URL` is unset in `.env.development`, so dev `robots.txt`/`sitemap.xml`/OG tags fall back
  to `http://localhost:3000`. Correct in `.env.production`; only a dev-preview caveat.
- `GET /api/v1/dev/last_otp/{phone}` is anonymous and Development-only, and the deployment runs as
  Development — so it is live on `api.balinyaar.ir`, returning any registered phone's login code.
