"""routes_pages.py — X2/Y1's read of a whole dashboard page: `GET /api/v1/pages/{key}`. ONE route serves every ported page. That is the wave-3 promise made mechanical: Collections and Procurement arrive as a builder registered in `pages.py`, and they inherit this route's session wall, grant wall, BU resolution, caching and error shape without a line being written here. WHY THE GATE IS INSIDE THE HANDLER and not `Depends(module_gate(...))`. `module_gate` closes over a FIXED module key at import; the key here is a PATH PARAMETER, so the check has to happen once the key is known. It is the same predicate (`Session.require` → `perms.may_open`), reached from `pages.build_envelope` so a future builder cannot be added without it. FAIL-CLOSED, and the order matters: no cookie -> 401 (require_session) unknown / unported page key -> 404 (a fact about our roadmap, not about this user) no grant for the page -> 403 (never an empty 200 — an empty page reads as "no data") a BU the account may not see -> 403 (never coerced to their own BU) a malformed bu -> 400 """ from fastapi import APIRouter, Depends, Query import pages # ⚠ Wave 16 (owner item 7): the module-level pages_sales import was REMOVED — the Sales page # is retired, so no builder registers in production and every /pages/{key} 404s (a roadmap # fact, not a grant fact). When the next builder lands (Collections / Procurement), import it # HERE at module level, not only lazily: deploy_web.py's import walk follows module-level # imports, and a builder registered from inside a function body is invisible to it — a # missing module CRASHED the live Space on 2026-07-04. (verify_api section G scans this file # for the retired import — phrase any mention of it so the scan cannot read prose as code.) from deps import Session, require_session router = APIRouter(prefix="/api/v1") @router.get("/pages/{key}") def page_data(key: str, bu: str = Query(default=None, description="'all' or a business-unit id. NARROWS the session's own " "scope; it can never widen it."), period: str = Query(default=None, description="Page-specific display parameter. The resolved value " "is echoed in controls[].value."), session: Session = Depends(require_session)): """The Y1 envelope for one page, scoped to this session's BU + own-book grant.""" return pages.build_envelope(session, key, bu=bu, period=period) @router.get("/pages") def page_index(session: Session = Depends(require_session)): """Which pages have published data, filtered to what this session may open. The shell needs this to know whether a nav entry leads to a ported page or to the Streamlit host during the strangler period — and it is `may_open`-filtered for the same reason `/api/v1/nav` is: the client renders what it is given and never decides who may see what. ⚠ AN EMPTY LIST IS A LEGITIMATE 200 HERE, and that is not a hole in the never-an-empty-200 rule. `/api/v1/nav` 403s on empty because a session that may open NOTHING is a misconfigured account. This route answers "which of your pages have been PORTED yet", and during the strangler period "none of them" is the ordinary, truthful answer for a user granted only surfaces that still live in the Streamlit host. Returning 403 would say they lack access to pages they can see. """ from deps import perms keys = [k for k in pages.known_pages() if perms.may_open(session.user, (pages.builder_for(k).MODULE))] return {"pages": keys}