"""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_ `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}