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)
This commit is contained in:
russell@unturf.com 2026-02-01 21:23:37 -05:00
parent c530e0f9dc
commit 8260897aeb
5 changed files with 352 additions and 10 deletions

124
CLAUDE.md
View file

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

View file

@ -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:

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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)

View file

@ -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: