From 8260897aebb92dc5b93d1e347b8557dd103d49b4 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 1 Feb 2026 21:23:37 -0500 Subject: [PATCH] Add node moderation API endpoints and enforce production rules - PATCH /api/v1/nodes/{id} now accepts disabled, approved, locked fields - DELETE /api/v1/nodes/{id} for permanent deletion (moderator only) - Python client: disable_node, enable_node, approve_node, lock_node, unlock_node, delete_node methods plus CLI commands - CLAUDE.md: production rules forbidding direct SQL, mandate API client - Fix modify_node.py Python 2 remnants (unicode, raw_input) --- CLAUDE.md | 124 ++++++++++++++++++++++++++++-- remarkbox/api/remarkbox_client.py | 85 ++++++++++++++++++++ remarkbox/api/views.py | 62 ++++++++++++++- remarkbox/scripts/modify_node.py | 6 +- remarkbox_client.py | 85 ++++++++++++++++++++ 5 files changed, 352 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3727921..9a31844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,15 +35,129 @@ When adding new columns or modifying the database schema: 4. **Clean up migration**: Remove extra autogenerated changes, keep only the new field 5. **Run migration**: `alembic -c development.ini upgrade head` -## Production Troubleshooting +## Ticket System -For read-only troubleshooting on production, use tmux-hosts: +Tracked issues live in `docs/tickets/`. Start every session by reading the index: -``` -tmux-hosts +```bash +cat docs/tickets/index.md ``` -**Important**: This is for read-only investigation only. Do not make changes to production systems. +- **Index**: `docs/tickets/index.md` is the master list. Always update it when creating or closing tickets. +- **Numbering**: Sequential. Next number = highest existing + 1. +- **Workflow**: Set status to `in-progress` when starting, `resolved` when done. Update `index.md` to match. +- **New tickets**: If you find a bug or get a feature request, create a new ticket file and add it to the index. +- **Sources**: Tickets reference community threads from `meta.remarkbox.com` and `faq.remarkbox.com` by UUID. + +## Remarkbox API and Python Client + +Remarkbox has a JSON API at `/api/v1/`. You can use it to read and write threads +on production as timehexon. The session cookie is saved at `~/.config/remarkbox/cookies.txt`. + +### Quick start (from a Python script in the scratchpad or inline) + +```python +import os, sys +sys.path.insert(0, "/home/fox/git/remarkbox/remarkbox/api") +from remarkbox_client import RemarkboxClient + +c = RemarkboxClient( + "https://my.remarkbox.com", + cookie_file=os.path.expanduser("~/.config/remarkbox/cookies.txt"), +) + +# Read +threads = c.list_threads("meta.remarkbox.com") +thread = c.get_thread("9f970183-ffaf-11f0-b565-040140774501") +node = c.get_node(node_id) +profile = c.get_profile() +ver = c.version() + +# Write (authenticated) +result = c.create_thread(namespace="meta.remarkbox.com", title="Title", data="Body") +result = c.reply(parent_node_id, data="Reply body") +c.edit_node(node_id, data="Updated body") +c.edit_node(node_id, title="Updated title") # title only for root nodes +c.update_profile("new-display-name") + +# Moderate (authenticated, moderator or owner) +c.disable_node(node_id) +c.enable_node(node_id) +c.approve_node(node_id) +c.lock_node(node_id) +c.unlock_node(node_id) +c.delete_node(node_id) # permanent, moderator only +``` + +### Key details + +- **Client source**: `remarkbox/api/remarkbox_client.py` (stdlib only, no pip) +- **API docs**: `docs/api.md` +- **Identity**: Authenticated as `timehexon@unturf.com` (display name: `timehexon`) +- **Journey thread**: `9f970183-ffaf-11f0-b565-040140774501` on `meta.remarkbox.com` -- update this after finishing work +- **Content limit**: 500,000 characters (~128k tokens) +- **Rate limits**: 120 reads/min, 30 writes/min (wait if you hit 429) + +### Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/version` | Deployed git commit hash | +| GET | `/api/v1/threads?namespace=X` | List threads | +| GET | `/api/v1/threads/{id}` | Thread with replies | +| POST | `/api/v1/threads` | Create thread | +| POST | `/api/v1/threads/{id}/replies` | Reply to thread | +| GET | `/api/v1/nodes/{id}` | Single node | +| PATCH | `/api/v1/nodes/{id}` | Edit node (data, title, disabled, approved, locked) | +| DELETE | `/api/v1/nodes/{id}` | Delete node permanently (moderator only) | +| POST | `/api/v1/auth/login` | Send OTP to email | +| POST | `/api/v1/auth/verify` | Verify OTP | +| GET | `/api/v1/user/profile` | Get profile | +| PATCH | `/api/v1/user/profile` | Update display name | +| GET | `/api/v1/clients/python` | Download Python client | + +### Authentication + +The saved cookie should work indefinitely. If it expires, you'll need an OTP: + +```python +c.login("timehexon@unturf.com") +# Ask the user for the 6-digit code from their email +c.verify("timehexon@unturf.com", "123456") +``` + +### Functional test + +Run the full idempotent test suite against production: + +```bash +env/bin/python remarkbox/api/functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com --name timehexon +``` + +This exercises all endpoints and updates the journey thread with results. + +## Production Rules + +**NEVER run direct SQL or raw database commands on production.** No `sqlite3`, no `UPDATE`, no `DELETE`, no direct file edits on the production database. Ever. If the API doesn't support what you need, add the endpoint first, push it, then use the client. + +**ALL production changes go through the API client.** Use `RemarkboxClient` with the saved cookie at `~/.config/remarkbox/cookies.txt`. This ensures authentication, audit trails, and proper ORM handling. + +**tmux-hosts is read-only.** You may use `tmux-hosts` to read logs, check processes, and investigate issues. You may NOT use it to modify data, run SQL, edit files, or restart services. + +```python +# RIGHT: Use the API client +c = RemarkboxClient("https://my.remarkbox.com", cookie_file="~/.config/remarkbox/cookies.txt") +c.disable_node(node_id) + +# WRONG: Never do this +# sqlite3 /opt/remarkbox/my.remarkbox.com.sqlite "UPDATE rb_node SET disabled=1 WHERE id='...'" +``` + +If a moderation operation is not yet supported by the API, the correct workflow is: +1. Add the endpoint to `remarkbox/api/views.py` +2. Add the method to `remarkbox/api/remarkbox_client.py` +3. Push, wait for deploy +4. Use the client ## Deployment Status diff --git a/remarkbox/api/remarkbox_client.py b/remarkbox/api/remarkbox_client.py index bc6597f..ccf25f0 100644 --- a/remarkbox/api/remarkbox_client.py +++ b/remarkbox/api/remarkbox_client.py @@ -296,6 +296,72 @@ class RemarkboxClient: raise ValueError("data or title is required") return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body) + def disable_node(self, node_id): + """Disable a node (requires authentication, moderator or owner). + + Args: + node_id: UUID of the node to disable + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"disabled": True}) + + def enable_node(self, node_id): + """Enable a previously disabled node (requires authentication, moderator or owner). + + Args: + node_id: UUID of the node to enable + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"disabled": False}) + + def approve_node(self, node_id): + """Approve a node (requires authentication, moderator or owner). + + Args: + node_id: UUID of the node to approve + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"approved": True}) + + def lock_node(self, node_id): + """Lock a thread (requires authentication, moderator or owner). Root nodes only. + + Args: + node_id: UUID of the root node to lock + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"locked": True}) + + def unlock_node(self, node_id): + """Unlock a thread (requires authentication, moderator or owner). Root nodes only. + + Args: + node_id: UUID of the root node to unlock + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"locked": False}) + + def delete_node(self, node_id): + """Delete a node permanently (requires moderator). + + Args: + node_id: UUID of the node to delete + + Returns: + dict with key: deleted (the node ID) + """ + return self._request("DELETE", "/api/v1/nodes/{}".format(node_id)) + # ----- Auth ----- def login(self, email=None): @@ -370,12 +436,19 @@ Commands: node Get a single node post <data> [name] Create thread (anonymous) reply <node_id> <data> [name] Reply to thread (anonymous) + disable <node_id> Disable a node (auth required) + enable <node_id> Enable a node (auth required) + approve <node_id> Approve a node (auth required) + lock <node_id> Lock a thread (auth required) + unlock <node_id> Unlock a thread (auth required) + delete <node_id> Delete a node (moderator only) login <email> Request OTP verify <email> <otp> Verify OTP Examples: python remarkbox_client.py https://my.remarkbox.com threads meta.remarkbox.com python remarkbox_client.py https://my.remarkbox.com post meta.remarkbox.com "Hello" "World" MyBot + python remarkbox_client.py https://my.remarkbox.com disable <node_id> """ if len(sys.argv) < 3: @@ -400,6 +473,18 @@ Examples: elif cmd == "reply" and len(args) >= 2: name = args[2] if len(args) > 2 else None result = client.reply(args[0], args[1], anonymous_name=name) + elif cmd == "disable" and len(args) >= 1: + result = client.disable_node(args[0]) + elif cmd == "enable" and len(args) >= 1: + result = client.enable_node(args[0]) + elif cmd == "approve" and len(args) >= 1: + result = client.approve_node(args[0]) + elif cmd == "lock" and len(args) >= 1: + result = client.lock_node(args[0]) + elif cmd == "unlock" and len(args) >= 1: + result = client.unlock_node(args[0]) + elif cmd == "delete" and len(args) >= 1: + result = client.delete_node(args[0]) elif cmd == "login" and len(args) >= 1: result = client.login(args[0]) elif cmd == "verify" and len(args) >= 2: diff --git a/remarkbox/api/views.py b/remarkbox/api/views.py index 16eb3da..3891461 100644 --- a/remarkbox/api/views.py +++ b/remarkbox/api/views.py @@ -542,9 +542,18 @@ def api_edit_node(request): data = body.get("data") or request.params.get("thread_data", "") title = body.get("title") or request.params.get("thread_title", "") - if not data and not title: + # Moderation flags (require can_alter_node, already checked above) + disabled = body.get("disabled") + approved = body.get("approved") + locked = body.get("locked") + + has_content_change = bool(data or title) + has_moderation_change = (disabled is not None or approved is not None + or locked is not None) + + if not has_content_change and not has_moderation_change: request.response.status_code = 400 - return {"error": "data or title is required"} + return {"error": "data, title, disabled, approved, or locked is required"} if data and len(data) > MAX_CONTENT_LENGTH: request.response.status_code = 400 @@ -559,12 +568,61 @@ def api_edit_node(request): if data: node.edit(data) + if disabled is True: + node.disable() + elif disabled is False: + node.enable() + + if approved is True: + node.approved = True + elif approved is False: + node.approved = False + + if locked is not None and node.is_root: + node.locked = bool(locked) + request.dbsession.add(node) request.dbsession.flush() return {"node": serialize_node(node)} +@view_config( + route_name="api-node-detail", + request_method="DELETE", + renderer="json", + require_csrf=False, +) +def api_delete_node(request): + """Delete a node permanently (requires moderator).""" + node_id = request.matchdict["node_id"] + node = get_node_by_id(request.dbsession, node_id) + + if node is None: + request.response.status_code = 404 + return {"error": "Node not found"} + + if not request.user or not request.user.authenticated: + request.response.status_code = 401 + return {"error": "Authentication required"} + + namespace = node.root.namespace + + denied = check_namespace_api_access(request, namespace) + if denied: + return denied + + if not namespace.is_moderator(request.user): + request.response.status_code = 403 + return {"error": "Only moderators can delete nodes"} + + node_id_str = str(node.id) + request.dbsession.delete(node) + request.dbsession.flush() + + return {"deleted": node_id_str} + + # --------------------------------------------------------------------------- # Authentication # --------------------------------------------------------------------------- diff --git a/remarkbox/scripts/modify_node.py b/remarkbox/scripts/modify_node.py index 25a79a7..d4250a5 100644 --- a/remarkbox/scripts/modify_node.py +++ b/remarkbox/scripts/modify_node.py @@ -79,8 +79,8 @@ def info_node(node): def get_arg_parser(): parser = base_parser("Modify a Node.") - parser.add_argument("-u", "--uri", type=unicode, default=None) - parser.add_argument("-i", "--id", type=unicode, default=None) + parser.add_argument("-u", "--uri", type=str, default=None) + parser.add_argument("-i", "--id", type=str, default=None) parser.add_argument("--show", default=False, action="store_true") parser.add_argument("--info", default=False, action="store_true") parser.add_argument("--move", default=False, metavar="NEW-PARENT-ID") @@ -143,7 +143,7 @@ def main(): if args.delete: if ( - raw_input("Delete node '{}' forever? [yes, no]: ".format(node.id)) + input("Delete node '{}' forever? [yes, no]: ".format(node.id)) == "yes" ): request.dbsession.delete(node) diff --git a/remarkbox_client.py b/remarkbox_client.py index bc6597f..ccf25f0 100644 --- a/remarkbox_client.py +++ b/remarkbox_client.py @@ -296,6 +296,72 @@ class RemarkboxClient: raise ValueError("data or title is required") return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body) + def disable_node(self, node_id): + """Disable a node (requires authentication, moderator or owner). + + Args: + node_id: UUID of the node to disable + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"disabled": True}) + + def enable_node(self, node_id): + """Enable a previously disabled node (requires authentication, moderator or owner). + + Args: + node_id: UUID of the node to enable + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"disabled": False}) + + def approve_node(self, node_id): + """Approve a node (requires authentication, moderator or owner). + + Args: + node_id: UUID of the node to approve + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"approved": True}) + + def lock_node(self, node_id): + """Lock a thread (requires authentication, moderator or owner). Root nodes only. + + Args: + node_id: UUID of the root node to lock + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"locked": True}) + + def unlock_node(self, node_id): + """Unlock a thread (requires authentication, moderator or owner). Root nodes only. + + Args: + node_id: UUID of the root node to unlock + + Returns: + dict with key: node + """ + return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), {"locked": False}) + + def delete_node(self, node_id): + """Delete a node permanently (requires moderator). + + Args: + node_id: UUID of the node to delete + + Returns: + dict with key: deleted (the node ID) + """ + return self._request("DELETE", "/api/v1/nodes/{}".format(node_id)) + # ----- Auth ----- def login(self, email=None): @@ -370,12 +436,19 @@ Commands: node <node_id> Get a single node post <namespace> <title> <data> [name] Create thread (anonymous) reply <node_id> <data> [name] Reply to thread (anonymous) + disable <node_id> Disable a node (auth required) + enable <node_id> Enable a node (auth required) + approve <node_id> Approve a node (auth required) + lock <node_id> Lock a thread (auth required) + unlock <node_id> Unlock a thread (auth required) + delete <node_id> Delete a node (moderator only) login <email> Request OTP verify <email> <otp> Verify OTP Examples: python remarkbox_client.py https://my.remarkbox.com threads meta.remarkbox.com python remarkbox_client.py https://my.remarkbox.com post meta.remarkbox.com "Hello" "World" MyBot + python remarkbox_client.py https://my.remarkbox.com disable <node_id> """ if len(sys.argv) < 3: @@ -400,6 +473,18 @@ Examples: elif cmd == "reply" and len(args) >= 2: name = args[2] if len(args) > 2 else None result = client.reply(args[0], args[1], anonymous_name=name) + elif cmd == "disable" and len(args) >= 1: + result = client.disable_node(args[0]) + elif cmd == "enable" and len(args) >= 1: + result = client.enable_node(args[0]) + elif cmd == "approve" and len(args) >= 1: + result = client.approve_node(args[0]) + elif cmd == "lock" and len(args) >= 1: + result = client.lock_node(args[0]) + elif cmd == "unlock" and len(args) >= 1: + result = client.unlock_node(args[0]) + elif cmd == "delete" and len(args) >= 1: + result = client.delete_node(args[0]) elif cmd == "login" and len(args) >= 1: result = client.login(args[0]) elif cmd == "verify" and len(args) >= 2: