loopable / api /routes_records.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ef68ae0 verified
Raw
History Blame Contribute Delete
6.95 kB
"""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}