loopable / api /routes_pages.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
da5297e verified
Raw
History Blame Contribute Delete
3.78 kB
"""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}