| """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") |
|
|
| |
| |
| _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_"): |
| |
| |
| |
| 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): |
| |
| 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", |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| @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} |
|
|