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:
parent
c530e0f9dc
commit
8260897aeb
5 changed files with 352 additions and 10 deletions
124
CLAUDE.md
124
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue