12 KiB
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";
the nearest requirement is "a customer can register and browse with only a verified phone" in
product/business/01-actors-and-onboarding.md
Integration: docs/integration/api-contract.md § The anonymous surface ·
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) |
| 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), 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.
| 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 |
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, welcome generateMetadata sets alternates.canonical = '/{locale}' |
'/' is never in PUBLIC_PATHS (the startsWith trap) |
routes.ts:205-210, 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 |
| 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 |
| 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. No guest search/profile route exists in the
client.
How to test
- Log in as nobody — this flow is the logged-out surface. Boot per testing-setup.md;
use a private window (or clear
access_token/refresh_token). - Prove the anonymous surface, no token. All seven returned
200:Expect: sevenfor 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"; done200s.nurses/1/trust_badge→isVerified: true,credentialTypes: ["criminal_record","moh_competency_license"];nurses/1/reviews→averageRating: 5.00, publishedCount: 1. A bareGET /search/nurses→400(«City Id must be greater than 0») — that is correct, not a defect. - 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 notitle). - Open
/fa. Expect: the same body as step 3 with the URL still/fa— the rewrite, not a redirect. - Open
/fa/termsand/fa/privacy. Expect: 8 and 7 numbered sections, each under the blue «این متن پیشنویس است…» draft banner. - Open
/fa/zzz-nopelogged 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. - Open
/fa/bookingslogged out. Expect:307 → /fa/login?next=%2Fbookings. curl /robots.txtand/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. Undernext devbare/returns404(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 devcannot 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:/faserved the customer Home's metadata (<title>اپلیکیشن خانواده | بالینیار</title>) instead of the welcome page,/bookingsreturned200rather than307,/fa/{profile,wallet,admin,nurse,select-role}all returned200, and noLink: …hreflangheader was emitted even thoughmiddleware.ts:62-65always copies one on. Every response carriedx-nextjs-cache: HIT/x-nextjs-prerender: 1— stale prerenders being served ahead of the middleware. A freshnpm run build+npm run starton:3001reproduced none of it; all gates behave exactly as coded. The gap is in the dev-server experience, not the product: anyone auditing guest routing againstnext devwill file bugs that do not exist.GET /api/v1/nurses/{id}/profilereturnsavatarUrlas afile:///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 becomesfile:///app/data/…, equally unusable. Blocks REQ-067 and breaks any avatar on a public profile.GET /api/v1/nurses/3/profilereturns200anonymously 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 incrementingnurseId.- No anonymous rate limit on the public reads:
SearchControllerandNursesControllercarry no[EnableRateLimiting], so guest traffic falls to the shared 100/min global per-IP limiter (REQ-066). /termsand/privacyship 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.tsxis 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/welcomehas no in-app link —ROUTES.WELCOMEappears only inmiddleware.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_URLis unset in.env.development, so devrobots.txt/sitemap.xml/OG tags fall back tohttp://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 onapi.balinyaar.ir, returning any registered phone's login code.