File size: 3,778 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da5297e
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
"""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}