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}
|