File size: 6,949 Bytes
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5351cc8
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5351cc8
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5351cc8
ea7b176
5351cc8
 
 
 
 
 
 
 
 
 
ef68ae0
 
 
 
 
 
5351cc8
ea7b176
 
5351cc8
 
ea7b176
 
5351cc8
ea7b176
5351cc8
 
 
 
 
ef68ae0
5351cc8
 
 
 
ea7b176
 
5351cc8
 
 
ea7b176
 
5351cc8
 
 
 
 
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
ef68ae0
5351cc8
 
 
 
ea7b176
 
5351cc8
 
 
ea7b176
 
5351cc8
 
 
 
 
 
 
ea7b176
5351cc8
 
 
 
 
 
 
 
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""Record detail routes: durable comments, scoped to the caller's book β€” ON EVERY DATABASE.

⭐ WAVE 19 (owner item 12). This file used to be the CUSTOMER record's comment routes with a
customer-shaped wall bolted to the module import line: `_in_book` asked
`routes_customers.allowed_pids` whatever surface the browser was on. Opening a PRODUCT record and
typing a comment therefore asked the customer book about a CRC32 hash of a SKU code, and the
panel answered "that customer is not in your book" β€” the owner's report. The dangerous half is
the one nobody sees: a hash that collides with a real partner id passes the wall, and the comment
is filed against somebody's customer where the whole team can read it.

THE SHAPE NOW: `?scope=` names the database (the same vocabulary `/workspace?scope=` and the
events route's `scopeKey` already speak), and `_pool_or_refuse` resolves BOTH halves of the wall
per scope β€” the GRANT and the ROW SET β€” by asking that topic's own route, never by re-deriving
one here:

    customer / cohort   `routes_customers.allowed_pids` behind the `customer_data` grant
    product             `routes_products.scoped_pool` behind the `product_data` grant
    ut_<slug>           `routes_tables.scoped_pool`, whose `_defn_or_refuse` IS the wall
                        (404 unknown / 403 not yours β€” a user table has no module grant)

⚠ THE PATH KEEPS ITS `/customers/` SEGMENT. It is the shipped URL and `verify_api.py`'s E1a
section pins it; the scope now travels beside it explicitly. A nicer noun is not worth churning
another session's gate mid-wave β€” the WALL is the query parameter, not the word.

⚠ NO DEFAULT BEYOND THE LEGACY ONE. An absent `scope` means `customer`, which is what every
shipped client sent and what keeps the old callers byte-identical; an UNRECOGNISED scope is a
400, never a silent fallback to the customer book (`routes_grid._scope_or_400`'s rule, and for
the same reason: a typo served as `customer` answers a question nobody asked).
"""
from fastapi import APIRouter, Body, Depends, Query

from deps import Session, err, require_session

router = APIRouter(prefix="/api/v1")

#: The customer topic's two names β€” one book, two surfaces (the Cohort page is the customer table
#: over hand-curated sets). Mirrors `modules.cohort.LEGACY_SCOPES` / `core.record_comments`.
_CUSTOMER_SCOPES = ("", "customer", "cohort")


def _scope_or_400(raw):
    scope = str(raw or "customer").strip().lower()
    if scope in _CUSTOMER_SCOPES or scope == "product" or scope.startswith("ut_"):
        return "customer" if scope in _CUSTOMER_SCOPES else scope
    raise err(400, "bad_scope",
              "scope must be customer, cohort, product or a ut_ database β€” refusing to guess")


def _pool_or_refuse(session: Session, scope: str):
    """The pids this session may attach comments to ON THIS DATABASE β€” grant wall included.

    Returns the frozenset. Raises the topic's own 403/404/503, so a caller who may not open the
    surface never learns anything about the row they asked about.
    """
    if scope == "product":
        from routes_products import MODULE as PRODUCT_MODULE, scoped_pool

        session.require(PRODUCT_MODULE)
        pids, _team, _rows, _fields = scoped_pool(session)
        return pids
    if scope.startswith("ut_"):
        # No module grant exists for a user table β€” `_defn_or_refuse` inside `scoped_pool` IS
        # the wall (creator or admin, fail-closed), and it answers 404 before 403 exactly as the
        # rows routes do.
        from routes_tables import scoped_pool

        pids, _rows, _fields, _defn = scoped_pool(session, scope)
        return pids
    from routes_customers import MODULE as CUSTOMER_MODULE, allowed_pids

    session.require(CUSTOMER_MODULE)
    return frozenset(allowed_pids(session))


def _in_book(pid, session, scope):
    if pid not in _pool_or_refuse(session, scope):
        # 403, not 404: the record may exist, but this session may not inspect it.
        raise err(403, "out_of_scope", "that record is not in your book")


def _unavailable():
    return err(
        503,
        "store_unavailable",
        "record comments are temporarily unavailable β€” no change was saved",
    )


# ⭐ WAVE 21 (D-17): the CANONICAL path is /records/{pid}/comments β€” comments hang off a RECORD
# in whatever topic `?scope=` names, and the customer-flavoured noun was wave-19 residue (the
# wall was always the query param). The old path stays as an ALIAS because the shipped client
# still calls it; verify_api pins the canonical path AND that the alias answers, so removing
# the alias later is a decision, never an accident.
@router.get("/records/{pid}/comments")
@router.get("/customers/{pid}/comments")
def comments(pid: int, scope: str = Query(default="customer"),
             session: Session = Depends(require_session)):
    from core import record_comments

    scope = _scope_or_400(scope)
    _in_book(pid, session, scope)
    try:
        rows = record_comments.list_comments(session.runtime, pid, scope=scope)
    except record_comments.CommentsUnavailable:
        raise _unavailable()
    return {"comments": rows}


@router.post("/records/{pid}/comments", status_code=201)
@router.post("/customers/{pid}/comments", status_code=201)
def create_comment(
    pid: int,
    body: dict = Body(default=None),
    scope: str = Query(default="customer"),
    session: Session = Depends(require_session),
):
    from core import record_comments

    scope = _scope_or_400(scope)
    _in_book(pid, session, scope)
    try:
        comment = record_comments.add_comment(
            session.runtime,
            pid,
            (body or {}).get("body"),
            session.uname,
            session.user.get("name") or session.uname,
            scope=scope,
        )
    except ValueError as exc:
        raise err(400, "bad_comment", str(exc))
    except record_comments.CommentsUnavailable:
        raise _unavailable()
    return {"comment": comment}


@router.delete("/records/{pid}/comments/{comment_id}")
@router.delete("/customers/{pid}/comments/{comment_id}")
def remove_comment(
    pid: int,
    comment_id: str,
    scope: str = Query(default="customer"),
    session: Session = Depends(require_session),
):
    from core import record_comments

    scope = _scope_or_400(scope)
    _in_book(pid, session, scope)
    try:
        deleted = record_comments.delete_comment(
            session.runtime,
            pid,
            comment_id,
            session.uname,
            admin=session.admin,
            scope=scope,
        )
    except record_comments.CommentForbidden:
        raise err(403, "comment_forbidden", "only the author may delete this comment")
    except record_comments.CommentsUnavailable:
        raise _unavailable()
    if not deleted:
        raise err(404, "comment_not_found", "that comment no longer exists")
    return {"ok": True, "id": comment_id}