The coach back-office for Evoli Fit.
HealthTypeScript · CSSGitHub @raedhellalOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
The coach back-office for Evoli Fit. Next.js 14 (App Router), React 18, TypeScript,
port 3300, light mode only.
Built for EV-183 (MVE-1 demo cut) on branch demo/evoli-pro-mve1, to the shapes
ADR-0012 decides. Both documents live in the hub repo:
• b-fit-mobile/docs/product/stories/EV-183-evoli-pro-demo-mve1.md
• b-fit-mobile/docs/architecture/adr/0012-coach-trainee-link.md
🔴 THIS REPO AUTO-DEPLOYS. A merge to main reaches production.
main is continuously deployed to Vercel at https://bfit-coach-seven.vercel.app.
Verify what is live with curl https://bfit-coach-seven.vercel.app/api/version.Read the full README ↗The coach back-office for Evoli Fit. Next.js 14 (App Router), React 18, TypeScript, port 3300, light mode only.
Built for EV-183 (MVE-1 demo cut) on branch demo/evoli-pro-mve1, to the shapes
ADR-0012 decides. Both documents live in the hub repo:
b-fit-mobile/docs/product/stories/EV-183-evoli-pro-demo-mve1.mdb-fit-mobile/docs/architecture/adr/0012-coach-trainee-link.md🔴 THIS REPO AUTO-DEPLOYS. A merge to
mainreaches production.mainis continuously deployed to Vercel at https://bfit-coach-seven.vercel.app. Verify what is live withcurl https://bfit-coach-seven.vercel.app/api/version.The previous banner here said this repo "merges to no
mainand is deployed nowhere ... no production, no public URL". That was false, and it was read and believed on 2026-09-22 while deciding whether a push was safe: 18 commits were pushed on the understanding that nothing would ship, and EV-188b and EV-201 went to production at 17:26 that day. Corrected whenstaff-engineerchecked the live URL during the EV-202b review instead of trusting this file.
GATE-MVE1being closed governs who is told about the portal. It does not stop a merge deploying it.
npm install
cp .env.example .env.local # then edit
npm run dev # http://localhost:3300
| Script | What it does |
|---|---|
npm run dev | dev server on :3300 |
npm run build | production build |
npm run start | serve the build on :3300 |
npm run test:e2e:refresh | the single-flight refresh spec, against qa/stub-api.mjs |
npm run test:e2e:activation | EV-278c's live path (sign-in check, token swap, Retry-After), against qa/activation-stub-api.mjs (:3304, stub :8097) |
npm run lint | next lint --dir src --dir qa --max-warnings=0 |
npm run typecheck | tsc --noEmit |
npm run test:e2e | Playwright smoke specs (fixture mode, boots its own server) |
npm run test:e2e:roster | the roster specs, on the populated fixture scenario (its own server, :3301) |
npm run test:e2e:chromium / test:e2e:roster:chromium | the same two suites, every test not tagged @webkit (BUG-1089). A Chromium-only verdict for the four tagged specs only: untagged WebKit legs in 28 other files still run here and fail without WebKit (« WebKit legs » below) |
npm run test:e2e:webkit / test:e2e:roster:webkit | the same two suites, the tagged WebKit legs only (41 tests in the four tagged specs; the untagged legs are not in it); red where WebKit is not installed |
test:e2e and test:e2e:roster are two suites, not one with a flag:
COACH_FIXTURE_SCENARIO is read once per dev-server process, the main suite needs
empty (EV-183 AC1's empty state and the invite happy path) and the roster's
scope-filtered nulls need rows. Both are gates — there is no CI here, so this table is
the checklist.
WebKit legs (BUG-1089). A spec that launches WebKit itself tags that test or describe
@webkit (WEBKIT_LEG / engineTag(engine) in qa/webkit-leg.ts). Both fixture configs
declare two projects: chromium runs every untagged test, webkit runs only the tagged legs.
A plain npm run test:e2e runs both, as before. The webkit project here is Desktop Chrome
whose legs launch WebKit themselves — unlike the real Desktop Safari webkit project of
playwright.start.config.ts and playwright.session.config.ts.
Tagged so far: only field-focus-ring, focus-clear-of-bars (default config),
field-focus-ring-roster and modal-focus-ring-roster (roster config). On a machine without
WebKit (QA's container), the coach gate's Chromium command for those four specs is
npx playwright test qa/field-focus-ring.spec.ts qa/focus-clear-of-bars.spec.ts --project=chromium
npx playwright test qa/field-focus-ring-roster.spec.ts qa/modal-focus-ring-roster.spec.ts \
--config playwright.roster.config.ts --project=chromium
For those four files only, its exit code is the Chromium verdict alone. The tagged WebKit legs
run only under --project=webkit, and there a missing WebKit fails every leg with « WebKit
is not installed: npx playwright install webkit » — never a pass, never a skip.
npm run test:e2e:alone -- --project=chromium accepts options only (a positional file is
refused; use ONLY=<substring>).
Not yet tagged (follow-up to BUG-1089): 211 WebKit legs in 28 other files — 22 under the
default config, 6 under the roster config — still launch WebKit inside chromium. Where WebKit
is missing they fail there, as they did before, so a --project=chromium run of a whole suite,
or of any of those files, is not a Chromium-only verdict. (Counted at BUG-1089 from a full
--project=chromium run of both suites without WebKit: tests failing on the WebKit-installed
assertion, plus WebKit-named tests not run after one.)
Evidence note (BUG-1089): the engineer's runs used Chromium 141 (/opt/pw-browsers
chromium-1194), symlinked into the chromium-1243 folder that Playwright 1.63 expects. A gate
run should use the pinned browser (npx playwright install chromium) where it is available.
One worker per fixture server, enforced. Every fixture test resets the server's whole
in-memory store first (EV-223), so both fixture suites refuse --workers above 1 before any
test runs (qa/fixture-single-worker.ts, BUG-249). To go faster, run more servers
(--shard, one job each), not more workers.
Demo on npm run dev, not npm run start. A production build served over plain
http on the LAN sets Secure cookies (NODE_ENV=production), the browser drops them,
and sign-in fails silently — the form posts, the api answers 200, and the coach lands
back on /login with no error to explain it.
Port 3300 is this surface's port — b-fit-api is 8080, b-fit-admin 3100,
evoli-landing 3200, and nothing else may claim 3300. If you keep an editor launch
config, this is the entry (.vscode/launch.json / .claude/launch.json shape):
{
"configurations": [
{
"name": "Evoli Pro (b-fit-coach) :3300",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"env": { "API_BASE_URL": "http://localhost:8080", "COACH_API_MODE": "live" }
}
]
}
See .env.example. Five variables, none of them secret, none of them shipped to the
browser:
| Variable | Default | Notes |
|---|---|---|
API_BASE_URL | http://localhost:8080 | b-fit-api. Trailing slashes stripped in src/lib/env.ts. |
COACH_API_MODE | live | fixture serves in-memory demo data instead of the api. |
COACH_FIXTURE_SCENARIO | populated | fixture only. empty = zero-trainee roster. |
INVITE_BASE_URL | http://localhost:3300 | The host the invite URL + QR encode; the link is <INVITE_BASE_URL>/i/<token>?coach=<name>. Set it to the Mac's LAN IP for the demo (ipconfig getifaddr en0, e.g. http://192.168.1.24:3300) and start Next with npm run dev -- -H 0.0.0.0, or a phone cannot reach it. |
COACH_INTAKE_REQUEST | unset (off) | EV-341w's release hold: on (exactly) shows the overview's « Entretien » card, its poll route GET /api/bff/clients/{id}/intake-consent and its two actions. Anything else, including unset, is off: no card, no intake read, the route 404s. Turn it on only in the deploy that carries EV-341b, after EV-341q is in a shipped app build. ⚠ Deployment-wide: on shows the card to every coach that deployment serves; it reads no account or allow-list, so it does not enforce D-PROV-1's test-accounts-only gate (src/lib/intakeRequestGate.ts). The default Playwright suite sets it. |
COACH_API_MODE=fixture swaps the whole api client for src/lib/coachApi.fixture.ts:
coach Alex R., trainee Lina M. on the Intermediate Muscle Building Routine, a
4-day streak, eight weekly weigh-ins from 71.2 kg to 70.4 kg, and one red flag. Dates
are computed relative to today on every call, so the fixture never goes stale.
It exists so the screens could be built and reviewed while ADR-0012 §D4 is still under
challenge — not so the product can pretend to have a backend. It is server-side and
env-gated, live is the default, and the demo runs live. In fixture mode the login
route mints an unsigned local token so middleware.ts has something to read; that
token is accepted by nothing outside this process.
COACH_API_MODE=fixture COACH_FIXTURE_SCENARIO=empty npm run dev
| Route | What |
|---|---|
/login | Credentials. The only page reachable without a session. |
/ | Roster: capacity meter, empty state or rows (needs-attention first), invite modal. |
/clients/[id] | Read-only trainee overview: four stat tiles, 8-week weight trend, red flags, revoke. |
/clients/denied | The trainee-is-not-on-your-roster page, served with 403. /clients/[id]'s layout redirects here when the api answers 403; middleware.ts sets the status, because a page render cannot. Carries no id and makes no api call. |
/activate | EV-278c: an account the admin initialised is finished here — new password + the person's own privacy/terms consent. The only page a PENDING session reaches (middleware sends it here from everywhere else; a coach session is sent from here to /). |
POST /api/auth/login | BFF sign-in — proxies POST /auth/login, sets the cookies. |
POST /api/auth/activate | EV-278c: proxies POST /me/activate for a PENDING session, refuses any consentAccepted that is not literal true before the api is called, and swaps both cookies to the fresh COACH tokens. |
POST /api/auth/logout | Clears both cookies. |
GET /api/bff/clients/{id}/intake-consent | EV-341w: the « Entretien » card's 3 s poll, a GET route handler (not an action: actions are dispatched one at a time). Behind middleware like a page; { granted, pending } / 403 / 502, no-store; 404 while COACH_INTAKE_REQUEST is not on. |
/clients/[id]/messages | EV-095b « Messages »: the thread with one client and a composer. Polls only while the tab is visible (15 s; 5 s for the 60 s after the coach's own send), through the BFF routes below, never a server action (ADR-0038 D38.1). No scope gates it: messaging belongs to the ACTIVE link. |
GET / POST /api/bff/clients/{id}/messages | EV-095b: the thread's read (?after= / ?before=) and the send { clientMessageId, body }. Behind middleware like a page, . The send checks , and the page's account (, 401 ) before the api; 429 carries . |
main auto-deploys to Vercel, and a 200 from the site is evidence of nothing —
the previous build answers 200 too. After a push, poll the deploy marker until it
reports the merge commit on main, exactly as b-fit-api is verified by polling
/actuator/info for build.commit. The poll is bounded (BUG-712): it ends with
DEPLOYED <sha> and exit 0, or NOT DEPLOYED within <N>s (serving <what it saw>) and
exit 1. Paste the function into the shell once:
# witness <url> <key: build.commit | commit> <expected full sha> <window seconds> [interval seconds]
witness() {
local w_url="$1" w_key="$2" w_want="$3" w_window="$4" w_every="${5:-30}" w_got w_end
w_end=$(( $(date +%s) + w_window ))
while :; do
w_got=$(curl -s --max-time 10 "$w_url" | python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
for k in sys.argv[1].split("."):
d = d[k]
print(d)
except Exception:
pass' "$w_key")
echo "$(date -u +%H:%M:%SZ) $w_url $w_key=${w_got:-<no answer>}"
if [ "$w_got" = "$w_want" ]; then echo "DEPLOYED $w_want"; return 0; fi
if [ "$(date +%s)" -ge "$w_end" ]; then echo "NOT DEPLOYED within ${w_window}s (serving ${w_got:-<no answer>})"; return 1; fi
sleep "$w_every"
done
}
# after the merge and the push, on main, in b-fit-coach (explicit cd, every time):
cd /Users/raedhelal/Desktop/b-fit/b-fit-coach && WANT=$(git rev-parse main)
witness https://bfit-coach-seven.vercel.app/api/version commit "$WANT" 300
witness() is copied verbatim from the hub's docs/qa/DEPLOY-CHECKLIST.md
§1 (BUG-709, hub main merge 8ba5cf8a), which polls this portal the same way in §1b.
The checklist is the reference copy: if the two ever differ, fix this one. It runs
under zsh and macOS /bin/bash 3.2 and needs only curl and python3. Its variables all
start with w_ (in zsh, path is tied to $PATH).git rev-parse main after the merge, never HEAD. Merges
are --no-ff, so Vercel builds the merge commit. From a branch or a train worktree HEAD
is a tip production never serves, and a healthy deploy times out. Run it after the push:
a main with unpushed merges is not what Vercel is building either./login) prints commit=<no answer>
and never matches: it is not a deploy.The SHA comes from Vercel's system environment variable VERCEL_GIT_COMMIT_SHA
(with VERCEL_ENV for environment), read in next.config.mjs at build time and
frozen into the output, with a runtime fallback. Off Vercel — npm run dev, or a
next build on your laptop — there is no such variable and the endpoint answers
"commit": "unknown", "environment": "local". It never guesses from local git:
a stale SHA would confirm a deploy that never happened.
cdg1, pinned in vercel.jsonEvery page is force-dynamic and calls b-fit-api server-side (the roster makes about
a dozen calls per render), so the function must run next to the api. Vercel runs new
projects' functions in iad1 (Washington, D.C.) by default; b-fit-api is in Railway's
EU West (Amsterdam), so until vercel.json pinned "regions": ["cdg1"] every api
call crossed the Atlantic. cdg1 (Paris) is the closest Vercel region to Amsterdam
together with fra1; cdg1 also matches where the coaches are.
vercel.json → regions). Do not add a second
region without checking the plan: too many regions fails the deployment before the
build step.vercel.json sets nothing but regions. The security headers stay in
next.config.mjs (headers()); do not add a headers key here./auth/refresh call starts from
there.Witness it on a deployment without signing in:
curl -sI https://bfit-coach-seven.vercel.app/login | grep -i x-vercel-id # <edge>::cdg1::…
curl -s https://bfit-coach-seven.vercel.app/api/version # "region":"cdg1"
region is VERCEL_REGION, read at runtime (Vercel documents it as runtime-only, and
only while the project exposes system environment variables); off Vercel it is
"unknown". The second segment of x-vercel-id is the authoritative witness either way.
Rollback: use Vercel Instant Rollback, or revert vercel.json and push. The dashboard
Function Region setting is ignored while vercel.json sets regions, so changing it
there does nothing.
src/lib/coachApi.ts composes exactly one shape, shown in the modal and QR-encoded
byte-identically (AC2):
<INVITE_BASE_URL>/i/<token>?coach=<url-encoded display name>
e.g. http://192.168.1.24:3300/i/<43-char base64url token>?coach=Alex%20R.
The ?coach= query is the coach's display name from GET /coach-portal/me, sanitised
by src/lib/inviteName.ts (plain text, no control or bidi characters, 60 characters).
It is there because b-fit-api has no pre-accept lookup for an invite token, so it is the
only way the app's consent screen can name the coach before the trainee accepts (AC3).
It is optional: a link without it still works and both sides say "Your coach".
/i/[token] is server-rendered, public (excluded from the middleware matcher) and
makes no API call — the token is opaque to this surface; b-fit-api validates it when
the app accepts. It shows the Evoli Fit wordmark, "Your coach invited you to Evoli", one
button linking to evolifit://my-coach/invite/<token>?coach=<name> (the query forwarded
untouched, and omitted when the link had none), and a fallback line for a phone
without the app (no store links yet — ⛔ D8 — so it says "coming soon" instead of
shipping a dead href). The token is never rendered as text, only inside that href.
It does not auto-redirect: iOS Safari only follows a custom scheme from a user
gesture, so it must stay a real <a href> the visitor taps.
A phone cannot resolve localhost, so both of these are required:
ipconfig getifaddr en0 # e.g. 192.168.1.24
INVITE_BASE_URL=http://192.168.1.24:3300 npm run dev -- -H 0.0.0.0
-H 0.0.0.0 makes Next listen on the LAN instead of the loopback interface; without it
the QR resolves to a host that refuses the connection. Phone and Mac must be on the same
Wi-Fi, and macOS may prompt to allow incoming connections for Node the first time.
This app is a BFF: the browser never holds a token and never calls b-fit-api.
POST /api/auth/login calls the api server-to-server and writes the access and
refresh tokens into httpOnly, SameSite=Lax, Secure-in-production cookies
(evoli_pro_at / evoli_pro_rt). They are not in the response body, not in
localStorage, and document.cookie cannot see them.src/middleware.ts guards every page except /login and the public /i/* (which it
handles first, allowing GET/HEAD and answering 405 otherwise), refreshes an expired access
token through POST /auth/refresh (it is the only place that can write the rotated
cookie back), requires COACH in the token's roles claim, and serves
/clients/denied with 403 — the one non-200 status in the app./api/auth/*, so those three handlers guard themselves: each
refuses a POST whose Origin is not the portal's own with 403 CROSS_ORIGIN
(src/lib/sameOrigin.ts), and /api/auth/activate — the one that reads the session —
rotates a missing or expired access token with the refresh cookie before it acts
(src/lib/routeSession.ts). The access cookie's Max-Age is the api's expiresIn
(15 minutes), so without that a slow form submits with no access cookie at all.(roster) route group so that its loading.tsx skeleton
belongs to / alone. A loading.tsx at the app root would flush every response
before the render begins, and /clients/[id]'s layout would then be unable to
redirect a denied read to the 403 page (it would degrade to a <meta refresh> served
with 200 — BUG-139).src/lib/apiFetch.ts attaches Authorization: Bearer on the server and retries once
through /auth/refresh on a 401. Concurrent calls from one render (the roster fetches
/coach-portal/me and /coach-portal/clients together) share a single in-flight
rotation keyed by the refresh token, so one 401 storm is one rotation — two would spend
the same refresh token twice and sign the coach out.force-dynamic and every fetch is no-store,
because a revoked link must be gone on the coach's very next reload (EV-183 AC6).b-fit-admin does the opposite (bearer token in localStorage, no middleware). That
model is deliberately not copied — per ADR-0012 finding 2, this app is EV-060's pattern
source.
Two limitations, stated rather than hidden:
/me. GET /me (UserResponse) returns id,
email, fullName, createdAt, emailVerified — and no roles. The roles claim in the
access token (JwtTokenService) is the only place the role is visible to this
surface, so src/lib/jwt.ts decodes it without verifying the signature. That is
safe here because the token was obtained server-to-server by this app and never
round-trips through a browser, and because the real authorization decision is always
the api's (CoachAccessGuard). If /me ever exposes roles, prefer it.b-fit-api has no /auth/logout and no refresh-token
revocation endpoint, so signing out drops the browser's copy but cannot invalidate
the refresh token server-side. EV-060 should add the endpoint.MFA: an account with MFA gets a challenge response (accessToken absent). Evoli
Pro does not implement the challenge step (EV-059 is a follow-up), so login fails with
a plain sentence saying so rather than crashing on a missing token.
Everything this surface knows about b-fit-api's coach portal lives in
src/lib/coachApi.ts — one module, ADR-0012 §D4's paths and the response types as
this app consumes them. Screens import coachApi, never apiFetch. When D4 moves under
the staff challenge, this file moves and no screen changes.
Every user-visible string is in src/lib/copy.ts (English, en) and
src/lib/copy.fr.ts (French, fr, EV-324). The strings marked AC are verbatim
acceptance-criteria text and QA verifies them character by character — a "tidy-up" of one
of those sentences is a story change. No invented benefits, no tier price (⛔ D8 is open).
Language (EV-324, re-ruled 2026-10-02). The coach's own choice wins: the FR/EN switch
in the account block (sidebar) or account menu (top bar) writes the evoli_pro_locale
cookie through a server action. Without it, the highest-weighted French or English
Accept-Language entry decides, and a browser that names neither gets French
(resolveLocale in src/lib/i18n/locale.ts). Playwright's Chromium sends no
Accept-Language unless a locale is set, so every config states locale: "en-US". A server component calls getCopy() (src/lib/i18n/server.ts), a
client component useCopy() (src/lib/i18n/client.tsx); nothing imports a dictionary
directly. fr is typed satisfies Copy, so a new string must be added in both files
or tsc fails; qa/coach-i18n.spec.ts also checks the enum-label maps tsc cannot.
Dates and numbers take the locale too — every formatter in src/lib/format.ts requires it
(copy.locale). qa/coach-french.spec.ts fails if a demo page shows an English UI string
to a French browser.
Tokens are copied verbatim from b-fit-admin/src/app/globals.css (itself the
design_handoff_evoli/web-tokens.css port), minus the dark block: off-white --bg,
white cards at --r-2xl (24 px) with a hairline border, pill chips. The Evoli Pro
redesign (2026-10-02, docs/product/design/evoli-pro-redesign-2026-10-02.md in the hub)
adds tokens without removing any: the primary button is --grad-energy-strong
(#3A5FE0 → #7440E0, ≥ 5.39:1 with white; --grad-energy is decoration only), the black
logo tile --brand-tile, --link, --r-12/--r-16, the --fs-* scale and
.layout-split (two columns from a 1280 px viewport). The black mark
(src/components/ui/brand.tsx, src/app/icon.svg, apple-icon.png) is Evoli Pro's only;
the trainee invite page keeps the trainee logo. The UI kit under src/components/ui/ is
copied, not extracted (ADR-0012 D5 — with two consumers a shared package is
premature; revisit at MVE-7). Do not fork the palette; re-copy from the admin.
Responsive to 320 px, with no viewport JavaScript: the wide and narrow layouts are both
server-rendered and CSS chooses (.only-wide / .only-narrow in globals.css). The shell
(CoachShell) is a 240 px sidebar from 1024 px and a top bar + bottom tab bar below it.
npx playwright install chromium # first run only
npm run test:e2e
qa/invite-landing.spec.ts covers the public /i/<token> page: it renders with no
session, the evolifit://my-coach/invite/<token> href is the only link on it, the token
never appears as text, and the tap target survives 390 px.
qa/coach-smoke.spec.ts runs in fixture mode with the empty scenario and covers the
login redirect, the roster empty state and its capacity sentence, both session
cookies being httpOnly (and that localStorage is empty), the 390 px
no-horizontal-scroll requirement, and the invite modal's link, QR and expiry sentence.
qa/refresh-single-flight.spec.ts runs from its own config against qa/stub-api.mjs, a
counting stand-in for b-fit-api that rotates its refresh token and reports how many times
/auth/refresh was called. It is not a second fixture — no screen may be built against
it; it exists so "one render, one rotation" is an assertion instead of a claim:
npm run test:e2e:refresh
no-storeOriginapplication/jsonx-evoli-session-subjectSESSION_CHANGEDretryAfterSecondsPOST /api/bff/clients/{id}/messages/read | EV-095b: mark read { seq }, sent by the page only while visible, so the roster's unread dot (GET /coach-portal/messages/unread, read once per roster load) clears. Same checks as the send. |
/notifications | EV-455f the inbox: the coach's items newest first (CLIENT_* catalogue rows only), unread ones data-unread="true", « Charger plus » per api cursor, « Tout marquer comme lu ». A click marks the item read and opens its client (/clients/{id}, or …/messages for a message). The list is read by its island through the BFF routes below. The bell (NotificationsBell, every signed-in page, both shells) reads unread-count on page load and every 60 s only while the tab is visible; a failed read draws no badge, never a 0. Real-api spec: qa/notifications.live.spec.ts (needs EV455F_PSQL, see qa/notifications-live.ts). |
GET /api/bff/notifications · GET …/unread-count · POST …/{id}/read · POST …/read-all | EV-455f: GET/POST /me/notifications… (EV-455b), behind middleware like a page, no-store. The two POSTs check Origin, application/json and x-evoli-session-subject (401 SESSION_CHANGED) before the api, as the thread's mark-read. |
GET /api/version | Public deploy marker — the portal's /actuator/info. { commit, commitShort, buildTime, environment, region }, never cached, GET/HEAD only. See below. |
/i/[token] | Public invite landing page — the page the QR encodes. Inside the middleware matcher, let through explicitly, GET/HEAD only (anything else answers 405). |