From 05a451ff75a281841ea136c44df965699a7f023b Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 18 Jul 2026 08:50:03 +0200 Subject: [PATCH] =?UTF-8?q?Registrerings-API-et=20er=20live=20p=C3=A5=20ht?= =?UTF-8?q?tps://teecup.teeoff.no.=20Oppsummert:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bygget: GET /public/tournaments/{id} og POST /public/tournaments/{id}/register — helt uautentisert, egen /public-prefiks. players.py utvidet med alle sju nye feltene. Grundig scratch-testet, ikke bare "kjørte uten feil": samtykke-avvisning, duplikat-avvisning, e-post-matching mot en organisator-forhåndsopprettet spiller (bekreftet ingen duplikat, mobil fylt inn, navn ikke overskrevet), kapasitet+venteliste, kapasitet+stengt, godkjenningskrav, utløpt frist — alle seks scenarioene fra ADR-en testet én etter én og ga riktig resultat. Notatet ditt om synlighet er fanget i FEATURE_BACKLOG.md, koblet til det samme åpne spørsmålet for «Banter Board»-feeden — før dette API-et ble bygget, ikke etter, slik du ba om. Gjenstår, bevisst utsatt: E-post-basert kontosammenkobling ved innlogging (ADR-017 Beslutning B sin andre halvdel) — trenger en ny SECURITY DEFINER-funksjon på tvers av org-er, altså migrasjon 008 siden 007 alt er kjørt mot prod. Ikke gjort i denne runden. Selve påmeldingsflyten er ikke testet med ekte data mot prod (kun ikke-destruktive sjekker: ukjent turnering ga korrekt 404). Landingssider — egen ADR-runde, som avtalt. --- .claude/settings.local.json | 7 +- CLAUDE.md | 50 ++++++-- FEATURE_BACKLOG.md | 23 +++- app/main.py | 3 +- app/routers/players.py | 52 ++++++-- app/routers/registration.py | 228 ++++++++++++++++++++++++++++++++++++ frontend/next.config.mjs | 1 + 7 files changed, 343 insertions(+), 21 deletions(-) create mode 100644 app/routers/registration.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f4739d0..d8624b4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -188,7 +188,12 @@ "Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)", "Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)", "Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)", - "Bash(docker rm -f teecup_scratch_api >/dev/null 2>&1 *)" + "Bash(docker rm -f teecup_scratch_api >/dev/null 2>&1 *)", + "Bash(python3 -m py_compile /opt/teecup/app/routers/players.py /opt/teecup/app/routers/registration.py /opt/teecup/app/main.py)", + "Bash(sed -i \"s#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#\" .env.scratch)", + "Bash(python3 -c \"import sys,json;print\\(json.load\\(sys.stdin\\)['confirmed_count']\\)\")", + "Bash(rm -f /opt/teecup/.env.scratch /tmp/owner_cookies.txt)", + "Bash(curl -s https://teecup.teeoff.no/public/tournaments/00000000-0000-0000-0000-000000000000)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 20c9b4e..19f854d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -387,15 +387,51 @@ Ferdig og verifisert: ukjent — OG et RÅTT `SELECT` på `tournament` på SAMME tilkobling/rolle ga fortsatt 0 rader, som beviser RLS ikke er brutt generelt, bare dette ene smale unntaket eksisterer. - **Kun migrasjon + ADR i denne runden** — API-endepunkter (offentlig - registrerings-router, utvidede `players.py`-felt) og frontend - (påmeldingsskjema) er IKKE bygget ennå, og migrasjonen er IKKE kjørt mot - ekte `teecup_db`. + **Kjørt mot ekte `teecup_db` 2026-07-18** (bruker bekreftet eksplisitt i + samme økt): migrasjonen kjørte rent, `test_isolation.sql` fortsatt 12/12. +- **Registrerings-API LIVE (2026-07-18), samme dag:** ny + `app/routers/registration.py` — `GET /public/tournaments/{id}` og + `POST /public/tournaments/{id}/register`, begge UTEN `get_current_user` + eller `get_authorized_org` (helt uautentisert, egen `/public`-prefiks, + bevisst atskilt fra `/orgs/...` i koden). Bruker `public_tournament_org()` + (migrasjon 007) til å slå opp org-kontekst FØR RLS kan håndheve noe. + `app/routers/players.py` utvidet med alle sju nye ADR-017-feltene + (`mobile`/`email`/`birth_date`/`nickname`/`country`/`club`/ + `club_member_number`). `frontend/next.config.mjs` sin `rewrites()` + utvidet med `/public/*` (ADR-016s konsekvens: enhver ny API-prefiks MÅ + inn her). + **Ny brukers-oppdaget notat fanget FØR bygging, ikke etter:** brukeren + krevde eksplisitt at synlighet (offentlig/kun org/kun turnering- + deltakere) må være et VALG for fremtidige landingssider — notert grundig + i `FEATURE_BACKLOG.md` (koblet til samme åpne spørsmål for "Banter + Board"-feeden) FØR dette registrerings-API-et ble bygget, akkurat for at + det ikke skal gå i glemmeboken til landingsside-runden. + **Verifisert grundig mot fersk `teecup_scratch`** (001→007, + `test_isolation.sql` 12/12): hele registreringsløpet testet reelt — + samtykke-avvisning (400), duplikat-avvisning (409 `DUPLICATE`), + e-post-matching mot en organisator-forhåndsopprettet spiller (BEKREFTET: + ingen duplikatrad, mobil fylt inn via `COALESCE`, `display_name` IKKE + overskrevet), kapasitet+`waitlist`-policy (→ `waitlisted`), + kapasitet+`closed`-policy (→ 409 `LIMIT_REACHED`), `registration_ + requires_approval` (→ `pending`), utløpt frist (→ 409 + `REGISTRATION_CLOSED`), og `confirmed_count` i `GET`-responsen talt + riktig (kun `confirmed`+`pending`, ikke `waitlisted`/avviste). + **Rullet ut live:** begge containere redeployet, `teecup.teeoff.no/ + dashboard` og `/health` fortsatt 200, `teeoff.no` upåvirket, det + offentlige endepunktet bekreftet nåbart over ekte https (ukjent + turnering-id ga korrekt `404`/`NOT_FOUND`, ikke-destruktiv sjekk — selve + påmeldingsflyten med ekte data ikke testet mot prod i denne runden). + **Gjenstår:** e-post-basert `player.user_id`-kobling ved innlogging + (ADR-017 Beslutning B sin andre halvdel — krever en egen + `SECURITY DEFINER`-funksjon på tvers av org-er, altså en ny migrasjon + 008, siden 007 allerede er kjørt mot prod). Frontend-påmeldingsskjema og + landingssider er egen, senere ADR-runde (se `FEATURE_BACKLOG.md`). Neste steg: -1. ADR-017: kjøre migrasjon 007 mot ekte `teecup_db` (venter på brukerens - eksplisitte bekreftelse), bygge API-et (offentlig registrerings-router, - utvidet `players.py`), så et påmeldingsskjema i frontend. +1. ADR-017: e-post-basert kontosammenkobling ved innlogging (migrasjon 008 + + `verify_magic_link`-utvidelse), så landingssider (turnering/org) med + påmeldingsskjema i + frontend — se brukerens landingsside-spørsmål, samme økt. 2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver gang — diff mot live-treet i et scratch-område før noe diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index a00ca2c..3d45556 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -38,7 +38,7 @@ | Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. | | Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). | | Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. | -| Selvregistrering + utvidet spillerprofil | 🔨 | ADR-017. Migrasjon 007 skrevet + scratch-verifisert. API/frontend gjenstår. | +| Selvregistrering + utvidet spillerprofil (API) | ✅ | ADR-017. `app/routers/registration.py` (offentlig, uautentisert), utvidet `players.py`. Live. Frontend-påmeldingsskjema/landingssider gjenstår (egen ADR-runde). | | Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. | --- @@ -397,7 +397,7 @@ | Del | Status | Notat | |---|---|---| | Lag-intern chat («det hemmelige rommet») | 🔨 | Bekreftet ønsket. Kanal m/ scope `team`. | -| Offentlig runde-feed («Banter Board») | ❓ | Synlighetsnivå ikke besluttet (deltakere/org/offentlig lenke). | +| Offentlig runde-feed («Banter Board») | ❓ | Synlighetsnivå ikke besluttet (deltakere/org/offentlig lenke) — SAMME trenivå-modell som landingssider under, bør trolig løses med ÉN delt mekanisme, ikke to separate. | | Bilder i feed/chat | 📋 | v1. Objektlagring (MinIO), presigned opplasting. | | Video | 💤 | Arkitekt for det, bygg senere (ADR-forslag). | | 1-til-1 direktemeldinger | 💤 | Gemini frarådet for v1; ikke etterspurt av deg. | @@ -405,6 +405,25 @@ --- +## Landingssider (turnering + organisasjon) — idé, ikke besluttet + +Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017). +Egen ADR-runde planlagt SENERE — for mange åpne spørsmål til å bygges +sammen med registrerings-API-et. + +| Del | Status | Notat | +|---|---|---| +| Synlighetsnivå: offentlig / kun org-medlemmer / kun turnering-deltakere | ❓ | **Eksplisitt krav fra brukeren, må ikke glemmes.** Samme trenivå-modell som «Banter Board» over — bør trolig dele mekanisme, ikke løses to ganger. Sannsynligvis et felt på turnering (og org?) som styrer hvilken av de tre gruppene som får se siden. | +| Turnering-landingsside: hero, tekst, program, sponsorer, påmelding | 📋 | Innhold bør endre seg med turneringens livssyklus (meld deg på → følg live → se resultater) — samme URL, ulikt hovedbudskap avhengig av dato/status. | +| Org-landingsside: klubbprofil, liste over turneringer | ❓ | Uavklart om dette i det hele tatt skal være en offentlig, søkbar profil, eller bare en intern hub med delbare turneringslenker. | +| Lesbar URL (slug) for organisasjon | 📋 | Trengs for at en delt lenke skal se ut som noe («teecup.teeoff.no/tjome-gents»), ikke en UUID. Nytt felt på `organization`. | +| Bilder (hero, sponsorlogoer) | 📋 | Krever objektlagring (MinIO) — arkitektur-invarianten finnes alt (se CLAUDE.md), men MinIO er ALDRI satt opp i praksis. Første funksjon som faktisk trenger det. | +| Del-metadata (Open Graph: og:title/og:image/og:description) | 📋 | Lett å glemme, høy verdi — dette ER siden folk faktisk deler i WhatsApp/e-post. | +| Blind draw-skjuling på offentlig side | 📋 | Må respektere ADR-013 (skjult oppstilling til begge lag er låst) akkurat som resten av appen — en offentlig side må IKKE lekke dette ved et uhell. | +| Antall påmeldte / ledige plasser vist åpent | 📋 | Bruker `tournament.registration_capacity` fra ADR-017 direkte, ingen ny data nødvendig. | + +--- + ## UX / frontend (senere fase) - 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp diff --git a/app/main.py b/app/main.py index ebaecad..bb05736 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from .db import init_pool, close_pool -from .routers import auth, matches, organizations, players, scoring, tournaments +from .routers import auth, matches, organizations, players, registration, scoring, tournaments @asynccontextmanager @@ -35,6 +35,7 @@ app.include_router(players.router) app.include_router(tournaments.router) app.include_router(matches.router) app.include_router(scoring.router) +app.include_router(registration.router) @app.get("/health") diff --git a/app/routers/players.py b/app/routers/players.py index e2b3196..07ebb94 100644 --- a/app/routers/players.py +++ b/app/routers/players.py @@ -3,10 +3,17 @@ Spillerpool på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av turneringe Kun org-medlemskap kreves (get_authorized_org) — å opprette en spiller i poolen er organisator-arbeid, ikke noe som forutsetter rostring på et lag. + +Utvidet profil (ADR-017): kontakt-/identitetsfelt brukt til å matche en +selvregistrerende spiller mot en allerede organisator-opprettet rad på +e-post (se app/routers/registration.py), og senere til å koble +app_user.id på (app/routers/auth.py sin verify_magic_link). """ +from datetime import date + from fastapi import APIRouter, Depends -from pydantic import BaseModel, Field +from pydantic import BaseModel, EmailStr, Field from ..auth import get_authorized_org from ..db import org_connection @@ -19,6 +26,13 @@ class PlayerCreate(BaseModel): display_name: str = Field(min_length=1, max_length=200) handicap_index: float | None = None gender: str | None = Field(default=None, pattern="^[mfx]$") + mobile: str | None = None + email: EmailStr | None = None + birth_date: date | None = None + nickname: str | None = None + country: str | None = None + club: str | None = None + club_member_number: str | None = None class Player(BaseModel): @@ -26,6 +40,19 @@ class Player(BaseModel): display_name: str handicap_index: float | None gender: str | None + mobile: str | None + email: str | None + birth_date: date | None + nickname: str | None + country: str | None + club: str | None + club_member_number: str | None + + +_PLAYER_COLUMNS = """ + id::text, display_name, handicap_index::float AS handicap_index, gender, + mobile, email, birth_date, nickname, country, club, club_member_number +""" @router.get("/orgs/{organization_id}/players", response_model=list[Player]) @@ -34,11 +61,7 @@ async def list_players( ) -> list[Player]: async with org_connection(organization_id) as conn: rows = await conn.fetch( - """ - SELECT id::text, display_name, handicap_index::float AS handicap_index, gender - FROM player - ORDER BY display_name - """ + f"SELECT {_PLAYER_COLUMNS} FROM player ORDER BY display_name" ) return [Player(**dict(r)) for r in rows] @@ -50,14 +73,23 @@ async def create_player( ) -> Player: async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( - """ - INSERT INTO player (organization_id, display_name, handicap_index, gender) - VALUES ($1, $2, $3, $4) - RETURNING id::text, display_name, handicap_index::float AS handicap_index, gender + f""" + INSERT INTO player + (organization_id, display_name, handicap_index, gender, + mobile, email, birth_date, nickname, country, club, club_member_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING {_PLAYER_COLUMNS} """, organization_id, body.display_name, body.handicap_index, body.gender, + body.mobile, + body.email.lower() if body.email else None, + body.birth_date, + body.nickname, + body.country, + body.club, + body.club_member_number, ) return Player(**dict(row)) diff --git a/app/routers/registration.py b/app/routers/registration.py new file mode 100644 index 0000000..c5ecad3 --- /dev/null +++ b/app/routers/registration.py @@ -0,0 +1,228 @@ +""" +Offentlig, uautentisert registrerings-API (ADR-017). + +Bevisst egen fil med prefiks /public/tournaments, atskilt fra /orgs/... -- +gjør sikkerhetsgrensen eksplisitt i koden: disse to endepunktene krever +VERKEN innlogging (get_current_user) ELLER org-medlemskap +(get_authorized_org), i motsetning til absolutt alt annet i API-et. + +`public_tournament_org()` (migrasjon 007, SECURITY DEFINER) er den ENESTE +broen fra en turnering-id til riktig RLS-kontekst FØR den er kjent -- se +ADR-017 Beslutning E for hvorfor dette er trygt (smalt unntak, ikke en +generell RLS-omgåelse). +""" + +from datetime import date, datetime, timezone + +from fastapi import APIRouter +from pydantic import BaseModel, EmailStr, Field + +from ..db import org_connection, plain_connection +from ..errors import app_error, translate_db_errors + +router = APIRouter(prefix="/public/tournaments", tags=["public-registration"]) + + +async def _resolve_org(tournament_id: str) -> str: + async with plain_connection() as conn: + org_id = await conn.fetchval("SELECT public_tournament_org($1)", tournament_id) + if org_id is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + return str(org_id) + + +class PublicTournamentInfo(BaseModel): + id: str + name: str + organization_name: str + status: str + start_date: date | None + end_date: date | None + registration_open: bool + registration_deadline: datetime | None + registration_capacity: int | None + confirmed_count: int + + +@router.get("/{tournament_id}", response_model=PublicTournamentInfo) +async def get_public_tournament(tournament_id: str) -> PublicTournamentInfo: + organization_id = await _resolve_org(tournament_id) + async with org_connection(organization_id) as conn: + row = await conn.fetchrow( + """ + SELECT t.id::text, t.name, o.name AS organization_name, t.status::text, + t.start_date, t.end_date, t.registration_deadline, + t.registration_capacity, + (SELECT count(*)::int FROM tournament_registration tr + WHERE tr.tournament_id = t.id AND tr.status IN ('confirmed', 'pending') + ) AS confirmed_count + FROM tournament t + JOIN organization o ON o.id = t.organization_id + WHERE t.id = $1 + """, + tournament_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + + deadline = row["registration_deadline"] + deadline_passed = deadline is not None and deadline < datetime.now(timezone.utc) + return PublicTournamentInfo( + id=row["id"], + name=row["name"], + organization_name=row["organization_name"], + status=row["status"], + start_date=row["start_date"], + end_date=row["end_date"], + registration_open=not deadline_passed, + registration_deadline=deadline, + registration_capacity=row["registration_capacity"], + confirmed_count=row["confirmed_count"], + ) + + +class RegisterRequest(BaseModel): + display_name: str = Field(min_length=1, max_length=200) + email: EmailStr | None = None + mobile: str | None = None + birth_date: date | None = None + nickname: str | None = None + country: str | None = None + club: str | None = None + club_member_number: str | None = None + gender: str | None = Field(default=None, pattern="^[mfx]$") + handicap_index: float | None = None + # API-et krever eksplisitt True -- ingen implisitt samtykke (ADR-017). + consent: bool + + +class RegistrationResult(BaseModel): + id: str + status: str + + +@router.post("/{tournament_id}/register", response_model=RegistrationResult, status_code=201) +async def register_for_tournament(tournament_id: str, body: RegisterRequest) -> RegistrationResult: + if not body.consent: + raise app_error(400, "VALIDATION_FAILED", "Samtykke må gis for å melde seg på.") + + organization_id = await _resolve_org(tournament_id) + + async with org_connection(organization_id) as conn, translate_db_errors(): + # Lås turneringsraden: forhindrer at to samtidige påmeldinger begge + # leser "under kapasitet" og begge kommer inn (TOCTOU) -- samme + # mønster som to-lags-grensen i tournaments.py sin create_team. + tournament = await conn.fetchrow( + """ + SELECT registration_deadline, registration_capacity, + registration_overflow_policy, registration_requires_approval + FROM tournament WHERE id = $1 FOR UPDATE + """, + tournament_id, + ) + if tournament is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + + deadline = tournament["registration_deadline"] + if deadline is not None and deadline < datetime.now(timezone.utc): + raise app_error(409, "REGISTRATION_CLOSED", "Påmeldingen er stengt.") + + # E-post-matching (ADR-017 Beslutning B): finn en allerede + # organisator-opprettet spillerrad med samme e-post og fyll inn + # manglende felt der, i stedet for å opprette en duplikat. + player_id = None + if body.email: + existing = await conn.fetchrow( + "SELECT id FROM player WHERE organization_id = $1 AND lower(email) = lower($2)", + organization_id, + body.email, + ) + if existing is not None: + player_id = existing["id"] + await conn.execute( + """ + UPDATE player SET + mobile = COALESCE(mobile, $2), + birth_date = COALESCE(birth_date, $3), + nickname = COALESCE(nickname, $4), + country = COALESCE(country, $5), + club = COALESCE(club, $6), + club_member_number = COALESCE(club_member_number, $7), + gender = COALESCE(gender, $8), + handicap_index = COALESCE(handicap_index, $9) + WHERE id = $1 + """, + player_id, + body.mobile, + body.birth_date, + body.nickname, + body.country, + body.club, + body.club_member_number, + body.gender, + body.handicap_index, + ) + + if player_id is None: + player_row = await conn.fetchrow( + """ + INSERT INTO player + (organization_id, display_name, handicap_index, gender, + mobile, email, birth_date, nickname, country, club, club_member_number) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id + """, + organization_id, + body.display_name, + body.handicap_index, + body.gender, + body.mobile, + body.email.lower() if body.email else None, + body.birth_date, + body.nickname, + body.country, + body.club, + body.club_member_number, + ) + player_id = player_row["id"] + + already = await conn.fetchval( + "SELECT id FROM tournament_registration WHERE tournament_id = $1 AND player_id = $2", + tournament_id, + player_id, + ) + if already is not None: + raise app_error(409, "DUPLICATE", "Denne spilleren er allerede påmeldt.") + + # Rekkefølge fra ADR-017 Beslutning C: frist -> kapasitet -> godkjenning. + status = "confirmed" + if tournament["registration_capacity"] is not None: + active_count = await conn.fetchval( + """ + SELECT count(*) FROM tournament_registration + WHERE tournament_id = $1 AND status IN ('confirmed', 'pending') + """, + tournament_id, + ) + if active_count >= tournament["registration_capacity"]: + if tournament["registration_overflow_policy"] == "waitlist": + status = "waitlisted" + else: + raise app_error(409, "LIMIT_REACHED", "Turneringen er full.") + + if status != "waitlisted" and tournament["registration_requires_approval"]: + status = "pending" + + row = await conn.fetchrow( + """ + INSERT INTO tournament_registration + (organization_id, tournament_id, player_id, status, consent_given_at) + VALUES ($1, $2, $3, $4, now()) + RETURNING id::text, status + """, + organization_id, + tournament_id, + player_id, + status, + ) + return RegistrationResult(id=row["id"], status=row["status"]) diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index c8ef96f..0e41a12 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -13,6 +13,7 @@ const nextConfig = { return [ { source: "/auth/:path*", destination: `${API_ORIGIN}/auth/:path*` }, { source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` }, + { source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` }, { source: "/health", destination: `${API_ORIGIN}/health` }, ] },