Add Python client, profile endpoint, and functional test

- remarkbox_client.py: stdlib-only Python client with cookie
  persistence, 3-tier config (args/env/file), CLI mode
- GET/PATCH /api/v1/user/profile: read and update display name
- GET /api/v1/clients/python: serve client for curl/wget download
- functional_test.py: idempotent live test that maintains a
  journey thread documenting each run
- Remove email2 spam honeypot from API (not useful for agents)
- Bump content limit to 500k chars (~128k tokens)
This commit is contained in:
russell@unturf.com 2026-02-01 16:39:19 -05:00
parent 9d59a3ca8a
commit 5a10e155bd
8 changed files with 1287 additions and 4 deletions

View file

@ -189,7 +189,7 @@ Request body:
- `namespace` (required)
- `title` (required)
- `data` (required, max 50000 chars)
- `data` (required, max 500000 chars)
- `anonymous_name` (optional, used when namespace allows anonymous)
- `email` (optional, creates an unverified user)
@ -219,7 +219,7 @@ Request body:
}
```
- `data` (required, max 50000 chars)
- `data` (required, max 500000 chars)
- `anonymous_name` (optional)
- `email` (optional)
@ -361,7 +361,7 @@ All errors return a JSON body with an `error` key:
| Status | Meaning |
|--------|---------|
| 400 | Bad request (missing params, content too long) |
| 401 | Authentication required or spam detected |
| 401 | Authentication required |
| 403 | Forbidden (locked thread, disabled node, namespace opt-out) |
| 404 | Not found (or API globally disabled) |
| 429 | Rate limit exceeded |

153
docs/testing.md Normal file
View file

@ -0,0 +1,153 @@
# Functional Testing the Remarkbox API
Walkthrough for testing every endpoint with `curl`. Replace
`REMARKBOX` with your deploy URL (e.g. `https://my.remarkbox.com`).
## Read Endpoints
### List Threads
```bash
curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" | python3 -m json.tool
```
### Get Thread
```bash
# grab the first thread id from the list
THREAD_ID=$(curl -s "$REMARKBOX/api/v1/threads?namespace=meta.remarkbox.com" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['threads'][0]['id'])")
curl -s "$REMARKBOX/api/v1/threads/$THREAD_ID" | python3 -m json.tool
```
### Get Node
```bash
curl -s "$REMARKBOX/api/v1/nodes/$THREAD_ID" | python3 -m json.tool
```
## Anonymous Posting
Requires a namespace with **Allow Anonymous Comments** enabled.
### Create Thread
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads" \
-H "Content-Type: application/json" \
-d '{
"namespace": "meta.remarkbox.com",
"title": "Test thread from curl",
"data": "Hello from the API.",
"anonymous_name": "CurlBot"
}' | python3 -m json.tool
```
### Reply to Thread
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads/$THREAD_ID/replies" \
-H "Content-Type: application/json" \
-d '{
"data": "Reply from curl.",
"anonymous_name": "CurlBot"
}' | python3 -m json.tool
```
## Authentication (Email OTP)
### Request OTP
```bash
curl -s -X POST "$REMARKBOX/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}' | python3 -m json.tool
```
### Verify OTP
Check your inbox for the 6-digit code, then:
```bash
curl -s -X POST "$REMARKBOX/api/v1/auth/verify" \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"email": "you@example.com", "otp": "123456"}' | python3 -m json.tool
```
The `-c cookies.txt` saves the session cookie for subsequent requests.
### Create Authenticated Thread
```bash
curl -s -X POST "$REMARKBOX/api/v1/threads" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"namespace": "meta.remarkbox.com",
"title": "Authenticated thread",
"data": "Posted with a verified session."
}' | python3 -m json.tool
```
### Edit a Node
```bash
curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"data": "Updated content."}' | python3 -m json.tool
```
## Error Cases
### Missing namespace
```bash
curl -s "$REMARKBOX/api/v1/threads" | python3 -m json.tool
# {"error": "namespace parameter is required"}
```
### Namespace with API access disabled
```bash
curl -s "$REMARKBOX/api/v1/threads?namespace=opted-out.example.com"
# {"error": "API access is disabled for this namespace"}
```
### Edit without auth
```bash
curl -s -X PATCH "$REMARKBOX/api/v1/nodes/$NODE_ID" \
-H "Content-Type: application/json" \
-d '{"data": "nope"}'
# {"error": "Authentication required"}
```
## Python Client
Download the Python client directly from the API:
```bash
curl -s "$REMARKBOX/api/v1/clients/python" -o remarkbox_client.py
```
Or with wget:
```bash
wget -q "$REMARKBOX/api/v1/clients/python" -O remarkbox_client.py
```
Then use it:
```python
from remarkbox_client import RemarkboxClient
client = RemarkboxClient("https://my.remarkbox.com")
threads = client.list_threads("meta.remarkbox.com")
for t in threads["threads"]:
print(t["title"])
```
See `remarkbox_client.py` header comments for full usage.

View file

@ -5,4 +5,6 @@ def includeme(config):
config.add_route("api-node-detail", "/api/v1/nodes/{node_id}")
config.add_route("api-auth-login", "/api/v1/auth/login")
config.add_route("api-auth-verify", "/api/v1/auth/verify")
config.add_route("api-user-profile", "/api/v1/user/profile")
config.add_route("api-client-python", "/api/v1/clients/python")
config.scan("remarkbox.api.views")

View file

@ -0,0 +1,364 @@
"""
Functional test of the Remarkbox API against a live instance.
Uses cookie persistence so authentication survives across runs.
First run requires an OTP; subsequent runs reuse the session.
Creates one persistent "API Testing Journey" thread and appends
a new section on each run. All other operations are reads.
Usage:
# First run (sends OTP, prompts for code):
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com
# With OTP on command line:
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com 173786
# Subsequent runs reuse saved session:
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com
# Set display name (idempotent):
python functional_test.py https://my.remarkbox.com meta.remarkbox.com timehexon@unturf.com --name timehexon
# Specify existing journey thread to append to:
python functional_test.py ... --journey 9f970183-ffaf-11f0-b565-040140774501
"""
import argparse
import datetime
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from remarkbox_client import RemarkboxClient, RemarkboxError
THREAD_TITLE = "API Testing Journey"
DEFAULT_COOKIE_DIR = os.path.join(os.path.expanduser("~"), ".config", "remarkbox")
PASS = "PASS"
FAIL = "FAIL"
class JournalWriter:
"""Collects test results and builds a markdown journal section."""
def __init__(self):
self.lines = []
self.results = []
self._step = 0
def step(self, title):
self._step += 1
self.lines.append("### {}. {}".format(self._step, title))
def note(self, text):
self.lines.append(text)
def blank(self):
self.lines.append("")
def log(self, test_name, passed, detail=""):
mark = "+" if passed else "!"
status = PASS if passed else FAIL
print(" [{}] {} {}{}".format(mark, status, test_name,
": " + detail if detail else ""))
self.results.append(passed)
return passed
@property
def passed(self):
return sum(1 for r in self.results if r)
@property
def total(self):
return len(self.results)
def summary_line(self):
return "**{}/{} passed.**".format(self.passed, self.total)
def render(self):
return "\n".join(self.lines)
def ensure_authenticated(client, email, otp=None):
"""Return True if authenticated, attempting login/verify if needed."""
try:
profile = client.get_profile()
if profile.get("user"):
return True, "reused session"
except RemarkboxError:
pass
# Request OTP on this client, then verify
login_result = client.login(email)
status = login_result.get("status")
if otp and status in ("sent", "throttled"):
try:
result = client.verify(email, otp)
if result.get("status") == "authenticated":
return True, "verified otp"
except RemarkboxError:
pass
# Need interactive OTP
if status == "throttled":
print("\n OTP already sent to {} (check inbox)".format(email))
else:
print("\n OTP sent to {}".format(email))
otp = input(" Enter 6-digit code: ").strip()
result = client.verify(email, otp)
return result.get("status") == "authenticated", "verified otp"
def find_journey_thread(client, namespace):
"""Find existing journey thread by title, or return None."""
data = client.list_threads(namespace)
for thread in data.get("threads", []):
if thread["title"].strip() == THREAD_TITLE:
return thread["id"]
return None
def run(url, namespace, email, otp=None, display_name=None, journey_id=None):
cookie_file = os.path.join(DEFAULT_COOKIE_DIR, "cookies.txt")
os.makedirs(DEFAULT_COOKIE_DIR, exist_ok=True)
client = RemarkboxClient(url, email=email, cookie_file=cookie_file)
j = JournalWriter()
now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
# ---------------------------------------------------------------
# Phase 1: Read operations (no trace)
# ---------------------------------------------------------------
print("\nPhase 1: Read operations")
j.step("List Threads")
try:
data = client.list_threads(namespace)
ok = data["namespace"]["name"] == namespace
j.log("list_threads", ok, "{} threads".format(len(data["threads"])))
j.note("Fetched {} threads from `{}`.".format(len(data["threads"]), namespace))
j.blank()
except Exception as e:
j.log("list_threads", False, str(e))
j.step("Get Thread")
thread_id = None
try:
if data["threads"]:
thread_id = data["threads"][0]["id"]
detail = client.get_thread(thread_id)
ok = "thread" in detail and "replies" in detail
j.log("get_thread", ok, "{} replies".format(len(detail["replies"])))
j.note("Read thread `{}` with {} replies.".format(
thread_id[:8], len(detail["replies"])))
j.blank()
except Exception as e:
j.log("get_thread", False, str(e))
j.step("Get Node")
try:
node_data = client.get_node(thread_id)
ok = node_data["node"]["id"] == thread_id
j.log("get_node", ok)
j.note("Fetched node `{}`.".format(thread_id[:8]))
j.blank()
except Exception as e:
j.log("get_node", False, str(e))
j.step("Error Handling")
try:
client.list_threads("")
j.log("error_400", False, "expected error")
except RemarkboxError as e:
j.log("error_400", e.status == 400, "HTTP {}".format(e.status))
try:
client.get_node("00000000-0000-0000-0000-000000000000")
j.log("error_404", False, "expected error")
except RemarkboxError as e:
j.log("error_404", e.status == 404, "HTTP {}".format(e.status))
j.note("Missing namespace -> 400, nonexistent node -> 404.")
j.blank()
# ---------------------------------------------------------------
# Phase 2: Authentication
# ---------------------------------------------------------------
print("\nPhase 2: Authentication")
j.step("Authenticate")
ok, method = ensure_authenticated(client, email, otp)
j.log("authenticate", ok, method)
j.note("Authenticated via {}.".format(method))
j.blank()
if not ok:
print("\n Authentication failed. Aborting.")
return j
# ---------------------------------------------------------------
# Phase 3: Profile (idempotent)
# ---------------------------------------------------------------
print("\nPhase 3: Profile")
j.step("Get Profile")
try:
profile = client.get_profile()
current_name = profile["user"]["name"]
j.log("get_profile", True, current_name)
j.note("Current display name: `{}`.".format(current_name))
j.blank()
profile_available = True
except RemarkboxError as e:
j.log("get_profile", False, "endpoint not available (HTTP {})".format(e.status))
j.note("Profile endpoint not deployed yet -- skipping profile tests.")
j.blank()
profile_available = False
if display_name and profile_available:
j.step("Update Display Name")
result = client.update_profile(display_name)
ok = result["user"]["name"] == display_name
j.log("update_profile", ok, "{} -> {}".format(current_name, display_name))
j.note("Set display name: `{}` -> `{}`.".format(current_name, display_name))
j.blank()
# Verify idempotent (run again, same name)
result2 = client.update_profile(display_name)
j.log("update_profile_idempotent", result2["user"]["name"] == display_name,
"same name accepted")
# Verify invalid name rejected
try:
client.update_profile("bad name!!!")
j.log("error_invalid_name", False, "expected error")
except RemarkboxError as e:
j.log("error_invalid_name", e.status == 400, "HTTP {}".format(e.status))
j.note("Invalid name correctly rejected with 400.")
j.blank()
# ---------------------------------------------------------------
# Phase 4: Write operations (single journey thread)
# ---------------------------------------------------------------
print("\nPhase 4: Write operations")
# Find or create the journey thread
if not journey_id:
journey_id = find_journey_thread(client, namespace)
if journey_id:
j.step("Reuse Journey Thread")
j.log("find_journey", True, journey_id[:8])
j.note("Found existing journey thread `{}`.".format(journey_id[:8]))
j.blank()
else:
j.step("Create Journey Thread")
create_result = client.create_thread(
namespace=namespace,
title=THREAD_TITLE,
data="# {}\n\nInitial creation.".format(THREAD_TITLE),
)
journey_id = create_result["node"]["id"]
j.log("create_thread", bool(journey_id), "node {}".format(journey_id[:8]))
j.note("Created journey thread `{}`.".format(journey_id[:8]))
j.blank()
# Reply
j.step("Reply")
reply_result = client.reply(
journey_id,
data="Test reply from run at {}. "
"Verifies `POST /api/v1/threads/{{node_id}}/replies`.".format(now),
)
reply_id = reply_result["node"]["id"]
j.log("reply", bool(reply_id), "node {}".format(reply_id[:8]))
j.note("Posted reply `{}`.".format(reply_id[:8]))
j.blank()
# Edit reply
j.step("Edit Reply")
client.edit_node(
reply_id,
data="Test reply from run at {} (edited). "
"Verifies `PATCH /api/v1/nodes/{{node_id}}`.".format(now),
)
j.log("edit_reply", True)
j.note("Edited reply `{}`.".format(reply_id[:8]))
j.blank()
# ---------------------------------------------------------------
# Phase 5: Readback
# ---------------------------------------------------------------
print("\nPhase 5: Readback")
j.step("Readback")
readback = client.get_thread(journey_id)
ok = readback["thread"]["title"].strip() == THREAD_TITLE
j.log("readback", ok, "{} replies".format(len(readback["replies"])))
j.note("Read back thread: {} replies.".format(len(readback["replies"])))
j.blank()
# ---------------------------------------------------------------
# Phase 6: Update journey thread body
# ---------------------------------------------------------------
print("\nPhase 6: Update journey thread")
j.note("---")
j.blank()
j.note(j.summary_line())
j.blank()
j.note("_Run at {} by `remarkbox_client.py`._".format(now))
# Build new body: keep existing content, append this run's section
existing_body = readback["thread"]["data"]
run_header = "\n\n## Run: {}\n\n".format(now)
updated_body = existing_body.rstrip() + run_header + j.render()
client.edit_node(journey_id, data=updated_body)
j.log("update_journey", True, "appended run section")
print("\n Journey: {}/api/v1/threads/{}".format(url, journey_id))
return j
def main():
parser = argparse.ArgumentParser(
description="Functional test of the Remarkbox API against a live instance."
)
parser.add_argument("url", help="Base URL (e.g. https://my.remarkbox.com)")
parser.add_argument("namespace", help="Namespace (e.g. meta.remarkbox.com)")
parser.add_argument("email", help="Email for authentication")
parser.add_argument("otp", nargs="?", default=None, help="OTP code (optional)")
parser.add_argument("--name", default=None,
help="Set display name (idempotent)")
parser.add_argument("--journey", default=None,
help="Existing journey thread ID to append to")
args = parser.parse_args()
print("Remarkbox API Functional Test")
print(" URL: {}".format(args.url))
print(" Namespace: {}".format(args.namespace))
print(" Email: {}".format(args.email))
if args.name:
print(" Name: {}".format(args.name))
if args.journey:
print(" Journey: {}".format(args.journey))
j = run(args.url, args.namespace, args.email,
otp=args.otp, display_name=args.name, journey_id=args.journey)
print("\n" + "=" * 40)
print("Results: {}/{}".format(j.passed, j.total))
if j.passed == j.total:
print("All tests passed.")
else:
print("Some tests failed.")
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,398 @@
"""
Remarkbox API Client (Python, stdlib only)
Download:
curl -s https://REMARKBOX/api/v1/clients/python -o remarkbox_client.py
wget -q https://REMARKBOX/api/v1/clients/python -O remarkbox_client.py
Quick start:
from remarkbox_client import RemarkboxClient
client = RemarkboxClient("https://my.remarkbox.com")
# List threads
result = client.list_threads("meta.remarkbox.com")
for thread in result["threads"]:
print(thread["title"])
# Read a thread and its replies
thread = client.get_thread(thread_id)
for reply in thread["replies"]:
print(reply["data"])
# Post anonymously (namespace must allow anonymous)
node = client.create_thread(
namespace="meta.remarkbox.com",
title="Hello from Python",
data="This is a test post.",
anonymous_name="MyBot",
)
# Reply to a thread
reply = client.reply(node["node"]["id"], data="Nice thread!")
# Authenticate via email OTP
client.login("agent@example.com")
# ... check inbox for 6-digit code ...
client.verify("agent@example.com", "123456")
# Now requests are authenticated
thread = client.create_thread(
namespace="meta.remarkbox.com",
title="Verified post",
data="Posted with a session.",
)
# Edit your own post
client.edit_node(thread["node"]["id"], data="Updated content.")
Configuration:
# From arguments (highest priority)
client = RemarkboxClient("https://my.remarkbox.com")
# From environment variables
# REMARKBOX_URL=https://my.remarkbox.com
# REMARKBOX_EMAIL=agent@example.com
client = RemarkboxClient.from_env()
# From config file (~/.config/remarkbox/config.json)
# {"url": "https://my.remarkbox.com", "email": "agent@example.com"}
client = RemarkboxClient.from_config()
Requires: Python 3.6+ (stdlib only, no pip install needed)
License: Same as Remarkbox
"""
import json
import os
import http.cookiejar
import urllib.request
import urllib.error
import urllib.parse
__version__ = "0.1.0"
class RemarkboxError(Exception):
"""Raised when the API returns an error response."""
def __init__(self, status, body):
self.status = status
self.body = body
msg = body.get("error", str(body)) if isinstance(body, dict) else str(body)
super().__init__("HTTP {}: {}".format(status, msg))
class RemarkboxClient:
"""Remarkbox API client. Manages sessions via cookies automatically."""
def __init__(self, url, email=None, cookie_file=None):
"""
Args:
url: Base URL of the Remarkbox instance (e.g. https://my.remarkbox.com)
email: Optional default email for login/verify
cookie_file: Optional path to persist session cookies across runs.
If provided, cookies are loaded on init and saved
after login/verify. Use this to stay logged in.
"""
self.url = url.rstrip("/")
self.email = email
self._cookie_file = cookie_file
if cookie_file:
self._cookie_jar = http.cookiejar.MozillaCookieJar(cookie_file)
if os.path.exists(cookie_file):
self._cookie_jar.load(ignore_discard=True, ignore_expires=True)
else:
self._cookie_jar = http.cookiejar.CookieJar()
self._opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self._cookie_jar)
)
@classmethod
def from_env(cls):
"""Create client from environment variables.
Reads:
REMARKBOX_URL (required)
REMARKBOX_EMAIL (optional)
"""
url = os.environ.get("REMARKBOX_URL")
if not url:
raise RemarkboxError(0, {"error": "REMARKBOX_URL environment variable not set"})
email = os.environ.get("REMARKBOX_EMAIL")
return cls(url, email=email)
@classmethod
def from_config(cls, path=None):
"""Create client from a JSON config file.
Default path: ~/.config/remarkbox/config.json
Config format:
{"url": "https://my.remarkbox.com", "email": "agent@example.com"}
"""
if path is None:
path = os.path.join(
os.path.expanduser("~"), ".config", "remarkbox", "config.json"
)
with open(path) as f:
config = json.load(f)
url = config.get("url")
if not url:
raise RemarkboxError(0, {"error": "url is required in config file"})
return cls(url, email=config.get("email"), cookie_file=config.get("cookie_file"))
def _save_cookies(self):
"""Persist cookies to disk if cookie_file was provided."""
if self._cookie_file and hasattr(self._cookie_jar, "save"):
self._cookie_jar.save(ignore_discard=True, ignore_expires=True)
def _request(self, method, path, body=None):
"""Make an HTTP request and return parsed JSON."""
url = self.url + path
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
resp = self._opener.open(req)
raw = resp.read().decode("utf-8")
try:
return json.loads(raw) if raw else {}
except json.JSONDecodeError:
raise RemarkboxError(resp.status, {"error": "Non-JSON response"})
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
body = json.loads(raw)
except Exception:
body = {"error": raw}
raise RemarkboxError(e.code, body)
# ----- Threads -----
def list_threads(self, namespace, page=1):
"""List threads in a namespace.
Args:
namespace: The namespace name (e.g. "meta.remarkbox.com")
page: Page number (default 1)
Returns:
dict with keys: namespace, threads, page, page_size
"""
params = urllib.parse.urlencode({"namespace": namespace, "page": page})
return self._request("GET", "/api/v1/threads?" + params)
def get_thread(self, node_id):
"""Get a thread and all its replies.
Args:
node_id: UUID of the root thread node
Returns:
dict with keys: namespace, thread, replies
"""
return self._request("GET", "/api/v1/threads/{}".format(node_id))
def create_thread(self, namespace, title, data, anonymous_name=None, email=None):
"""Create a new thread.
Args:
namespace: Target namespace name
title: Thread title
data: Markdown content (max 500000 chars)
anonymous_name: Name for anonymous posting (optional)
email: Email to associate with post (optional)
Returns:
dict with keys: node, verified
"""
body = {"namespace": namespace, "title": title, "data": data}
if anonymous_name:
body["anonymous_name"] = anonymous_name
if email:
body["email"] = email
return self._request("POST", "/api/v1/threads", body)
# ----- Replies -----
def reply(self, node_id, data, anonymous_name=None, email=None):
"""Reply to a thread or another reply.
Args:
node_id: UUID of the parent node (thread or reply)
data: Markdown content (max 500000 chars)
anonymous_name: Name for anonymous posting (optional)
email: Email to associate with post (optional)
Returns:
dict with keys: node, verified
"""
body = {"data": data}
if anonymous_name:
body["anonymous_name"] = anonymous_name
if email:
body["email"] = email
return self._request("POST", "/api/v1/threads/{}/replies".format(node_id), body)
# ----- Nodes -----
def get_node(self, node_id):
"""Get a single node by ID.
Args:
node_id: UUID of the node
Returns:
dict with key: node
"""
return self._request("GET", "/api/v1/nodes/{}".format(node_id))
def edit_node(self, node_id, data=None, title=None):
"""Edit a node (requires authentication).
Args:
node_id: UUID of the node to edit
data: New markdown content (optional)
title: New title, only for root nodes (optional)
Returns:
dict with key: node
"""
body = {}
if data is not None:
body["data"] = data
if title is not None:
body["title"] = title
if not body:
raise ValueError("data or title is required")
return self._request("PATCH", "/api/v1/nodes/{}".format(node_id), body)
# ----- Auth -----
def login(self, email=None):
"""Request an OTP code be sent to the email address.
Args:
email: Email address (uses self.email if not provided)
Returns:
dict with keys: status, message
"""
email = email or self.email
if not email:
raise ValueError("email is required")
return self._request("POST", "/api/v1/auth/login", {"email": email})
def verify(self, email=None, otp=None):
"""Verify an OTP code and establish an authenticated session.
After calling this, subsequent requests are authenticated
via the session cookie (managed automatically).
Args:
email: Email address (uses self.email if not provided)
otp: The 6-digit verification code from email
Returns:
dict with keys: status, user
"""
email = email or self.email
if not email:
raise ValueError("email is required")
if not otp:
raise ValueError("otp is required")
result = self._request("POST", "/api/v1/auth/verify", {"email": email, "otp": otp})
self._save_cookies()
return result
# ----- Profile -----
def get_profile(self):
"""Get the current authenticated user's profile.
Returns:
dict with key: user (id, name, email)
"""
return self._request("GET", "/api/v1/user/profile")
def update_profile(self, name):
"""Update the current user's display name.
Args:
name: New display name (alphanumeric and dashes only)
Returns:
dict with key: user (id, name, email)
"""
return self._request("PATCH", "/api/v1/user/profile", {"name": name})
# ----- CLI -----
def main():
"""Simple CLI for quick testing."""
import sys
usage = """Usage: python remarkbox_client.py <url> <command> [args...]
Commands:
threads <namespace> List threads
thread <node_id> Get thread with replies
node <node_id> Get a single node
post <namespace> <title> <data> [name] Create thread (anonymous)
reply <node_id> <data> [name] Reply to thread (anonymous)
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
"""
if len(sys.argv) < 3:
print(usage)
sys.exit(1)
url = sys.argv[1]
cmd = sys.argv[2]
args = sys.argv[3:]
client = RemarkboxClient(url)
try:
if cmd == "threads" and len(args) >= 1:
result = client.list_threads(args[0])
elif cmd == "thread" and len(args) >= 1:
result = client.get_thread(args[0])
elif cmd == "node" and len(args) >= 1:
result = client.get_node(args[0])
elif cmd == "post" and len(args) >= 3:
name = args[3] if len(args) > 3 else None
result = client.create_thread(args[0], args[1], args[2], anonymous_name=name)
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 == "login" and len(args) >= 1:
result = client.login(args[0])
elif cmd == "verify" and len(args) >= 2:
result = client.verify(args[0], args[1])
else:
print(usage)
sys.exit(1)
print(json.dumps(result, indent=2))
except RemarkboxError as e:
print(json.dumps({"error": str(e), "status": e.status}, indent=2), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,5 +1,7 @@
import os
import re
from pyramid.response import Response
from pyramid.view import view_config
from remarkbox.models import (
@ -8,7 +10,11 @@ from remarkbox.models import (
get_or_create_user_surrogate_by_name,
get_nodes_who_share_root,
)
from remarkbox.models.user import get_or_create_user_by_email
from remarkbox.models.user import (
get_or_create_user_by_email,
is_user_name_valid,
is_user_name_available,
)
from remarkbox.models.namespace import get_or_create_namespace
from remarkbox.lib.mail import send_verification_digits_to_email
from remarkbox.lib.notify import schedule_notifications
@ -492,3 +498,95 @@ def api_auth_verify(request):
request.response.status_code = 401
return {"error": "Invalid verification code"}
# ---------------------------------------------------------------------------
# User Profile
# ---------------------------------------------------------------------------
@view_config(
route_name="api-user-profile",
request_method="GET",
renderer="json",
require_csrf=False,
)
def api_get_profile(request):
"""Get the current user's profile."""
if not request.user or not request.user.authenticated:
request.response.status_code = 401
return {"error": "Authentication required"}
return {
"user": {
"id": str(request.user.id),
"name": request.user.name,
"email": request.user.email,
},
}
@view_config(
route_name="api-user-profile",
request_method="PATCH",
renderer="json",
require_csrf=False,
)
def api_update_profile(request):
"""Update the current user's profile (display name)."""
if not request.user or not request.user.authenticated:
request.response.status_code = 401
return {"error": "Authentication required"}
body = get_json_body(request)
name = body.get("name", "").strip()
if not name:
request.response.status_code = 400
return {"error": "name is required"}
if not is_user_name_valid(name):
request.response.status_code = 400
return {"error": "name must be alphanumeric (dashes allowed)"}
# Allow setting to current name (idempotent)
if name.lower() != request.user.name.lower():
if not is_user_name_available(request.dbsession, name):
request.response.status_code = 409
return {"error": "name is already taken"}
request.user.name = name
request.dbsession.add(request.user)
request.dbsession.flush()
return {
"user": {
"id": str(request.user.id),
"name": request.user.name,
"email": request.user.email,
},
}
# ---------------------------------------------------------------------------
# Clients
# ---------------------------------------------------------------------------
_client_dir = os.path.dirname(os.path.abspath(__file__))
@view_config(
route_name="api-client-python",
request_method="GET",
require_csrf=False,
)
def api_client_python(request):
"""Serve the Python client for agents to download."""
path = os.path.join(_client_dir, "remarkbox_client.py")
with open(path) as f:
content = f.read()
return Response(
body=content,
content_type="text/plain",
charset="utf-8",
)

View file

@ -0,0 +1,254 @@
import json
import os
import tempfile
import unittest
from unittest import mock
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading
class FakeHandler(BaseHTTPRequestHandler):
"""Minimal HTTP handler for testing the client."""
routes = {}
def do_GET(self):
self._handle()
def do_POST(self):
self._handle()
def do_PATCH(self):
self._handle()
def _handle(self):
content_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_len) if content_len else b""
parsed = json.loads(body) if body else {}
# Store the request for assertions
FakeHandler.last_request = {
"method": self.command,
"path": self.path,
"body": parsed,
"headers": dict(self.headers),
}
path = self.path.split("?")[0]
key = (self.command, path)
if key in self.routes:
status, response = self.routes[key]
else:
status, response = 200, {"ok": True}
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(response).encode())
def log_message(self, format, *args):
pass # Suppress logs
class TestRemarkboxClient(unittest.TestCase):
"""Unit tests for the Python client."""
@classmethod
def setUpClass(cls):
cls.server = HTTPServer(("127.0.0.1", 0), FakeHandler)
cls.port = cls.server.server_address[1]
cls.base_url = "http://127.0.0.1:{}".format(cls.port)
cls.thread = threading.Thread(target=cls.server.serve_forever)
cls.thread.daemon = True
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def setUp(self):
FakeHandler.routes = {}
FakeHandler.last_request = None
def _make_client(self):
from remarkbox.api.remarkbox_client import RemarkboxClient
return RemarkboxClient(self.base_url)
def test_list_threads(self):
FakeHandler.routes[("GET", "/api/v1/threads")] = (
200,
{"threads": [{"id": "abc", "title": "Test"}], "page": 1},
)
client = self._make_client()
result = client.list_threads("example.com")
self.assertEqual(result["threads"][0]["title"], "Test")
self.assertIn("namespace=example.com", FakeHandler.last_request["path"])
def test_get_thread(self):
FakeHandler.routes[("GET", "/api/v1/threads/abc-123")] = (
200,
{"thread": {"id": "abc-123"}, "replies": []},
)
client = self._make_client()
result = client.get_thread("abc-123")
self.assertEqual(result["thread"]["id"], "abc-123")
def test_create_thread(self):
FakeHandler.routes[("POST", "/api/v1/threads")] = (
201,
{"node": {"id": "new-1"}, "verified": True},
)
client = self._make_client()
result = client.create_thread(
"example.com", "Title", "Body", anonymous_name="Bot"
)
self.assertEqual(result["node"]["id"], "new-1")
body = FakeHandler.last_request["body"]
self.assertEqual(body["namespace"], "example.com")
self.assertEqual(body["title"], "Title")
self.assertEqual(body["anonymous_name"], "Bot")
def test_reply(self):
FakeHandler.routes[("POST", "/api/v1/threads/abc/replies")] = (
201,
{"node": {"id": "reply-1"}, "verified": True},
)
client = self._make_client()
result = client.reply("abc", "Reply text", anonymous_name="Bot")
self.assertEqual(result["node"]["id"], "reply-1")
self.assertEqual(FakeHandler.last_request["body"]["data"], "Reply text")
def test_get_node(self):
FakeHandler.routes[("GET", "/api/v1/nodes/node-1")] = (
200,
{"node": {"id": "node-1", "data": "content"}},
)
client = self._make_client()
result = client.get_node("node-1")
self.assertEqual(result["node"]["id"], "node-1")
def test_edit_node(self):
FakeHandler.routes[("PATCH", "/api/v1/nodes/node-1")] = (
200,
{"node": {"id": "node-1", "data": "updated"}},
)
client = self._make_client()
result = client.edit_node("node-1", data="updated")
self.assertEqual(FakeHandler.last_request["body"]["data"], "updated")
def test_edit_node_requires_data_or_title(self):
client = self._make_client()
with self.assertRaises(ValueError):
client.edit_node("node-1")
def test_login(self):
FakeHandler.routes[("POST", "/api/v1/auth/login")] = (
200,
{"status": "sent", "message": "Code sent."},
)
client = self._make_client()
result = client.login("test@example.com")
self.assertEqual(result["status"], "sent")
self.assertEqual(
FakeHandler.last_request["body"]["email"], "test@example.com"
)
def test_verify(self):
FakeHandler.routes[("POST", "/api/v1/auth/verify")] = (
200,
{"status": "authenticated", "user": {"id": "u1"}},
)
client = self._make_client()
result = client.verify("test@example.com", "123456")
self.assertEqual(result["status"], "authenticated")
body = FakeHandler.last_request["body"]
self.assertEqual(body["email"], "test@example.com")
self.assertEqual(body["otp"], "123456")
def test_login_requires_email(self):
client = self._make_client()
with self.assertRaises(ValueError):
client.login()
def test_verify_requires_otp(self):
client = self._make_client()
with self.assertRaises(ValueError):
client.verify("test@example.com")
def test_error_raises_remarkbox_error(self):
from remarkbox.api.remarkbox_client import RemarkboxError
FakeHandler.routes[("GET", "/api/v1/threads")] = (
400,
{"error": "namespace parameter is required"},
)
client = self._make_client()
with self.assertRaises(RemarkboxError) as ctx:
client.list_threads("")
self.assertEqual(ctx.exception.status, 400)
self.assertIn("namespace", str(ctx.exception))
def test_default_email_used(self):
from remarkbox.api.remarkbox_client import RemarkboxClient
FakeHandler.routes[("POST", "/api/v1/auth/login")] = (
200,
{"status": "sent"},
)
client = RemarkboxClient(self.base_url, email="default@example.com")
client.login()
self.assertEqual(
FakeHandler.last_request["body"]["email"], "default@example.com"
)
def test_from_env(self):
from remarkbox.api.remarkbox_client import RemarkboxClient
with mock.patch.dict(
os.environ,
{"REMARKBOX_URL": "https://test.example.com", "REMARKBOX_EMAIL": "a@b.com"},
):
client = RemarkboxClient.from_env()
self.assertEqual(client.url, "https://test.example.com")
self.assertEqual(client.email, "a@b.com")
def test_from_env_missing_url(self):
from remarkbox.api.remarkbox_client import RemarkboxClient, RemarkboxError
with mock.patch.dict(os.environ, {}, clear=True):
# Remove the keys if they exist
os.environ.pop("REMARKBOX_URL", None)
with self.assertRaises(RemarkboxError):
RemarkboxClient.from_env()
def test_from_config(self):
from remarkbox.api.remarkbox_client import RemarkboxClient
config = {"url": "https://config.example.com", "email": "c@d.com"}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config, f)
f.flush()
try:
client = RemarkboxClient.from_config(f.name)
self.assertEqual(client.url, "https://config.example.com")
self.assertEqual(client.email, "c@d.com")
finally:
os.unlink(f.name)
def test_url_trailing_slash_stripped(self):
from remarkbox.api.remarkbox_client import RemarkboxClient
client = RemarkboxClient("https://example.com/")
self.assertEqual(client.url, "https://example.com")
def test_cookies_persist_across_requests(self):
"""Verify session cookies are maintained (important for auth flow)."""
FakeHandler.routes[("GET", "/api/v1/threads")] = (
200,
{"threads": [], "page": 1},
)
client = self._make_client()
# Make two requests - the cookie jar should persist
client.list_threads("a.com")
client.list_threads("b.com")
# No error means the opener and cookie jar survived both calls

View file

@ -849,3 +849,17 @@ class TestAPINamespaceOptOut(APIFunctionalTests):
expect_errors=True,
)
self.assertEqual(res.status_int, 403)
class TestAPIClientDownload(APIFunctionalTests):
"""Test the client download endpoint."""
def test_download_python_client(self):
res = self.testapp.get("/api/v1/clients/python")
self.assertEqual(res.status_int, 200)
self.assertIn("text/plain", res.content_type)
self.assertIn("RemarkboxClient", res.text)
self.assertIn("def list_threads", res.text)
self.assertIn("def reply", res.text)
self.assertIn("def login", res.text)
self.assertIn("def verify", res.text)