Add registry improvements, tag filtering, and admin tools

Registry API improvements:
- Add /api/v1/me POST endpoint for profile updates
- Add /api/v1/me/password POST endpoint for password changes
- Fix visibility filtering on categories and tags endpoints to only
  show approved public tools
- Allow underscores in tool names for fabric pattern compatibility
- Fix sqlite3.Row access (use bracket notation instead of .get())

Web UI improvements:
- Add tag filter to /tools page with three-state buttons
  (include/exclude/neutral)
- Add mobile-friendly tag filter in responsive view
- Display tags on tool cards in the tools listing
- Add dashboard settings form handlers for profile and password

Admin improvements:
- Add scrutiny audit page for reviewing tool safety analysis
- Improve pending tools page with scrutiny report display
- Add scrutiny stats to admin dashboard

Fabric sync improvements:
- Add direct database publishing with scrutiny vetting
- Support auto-approve for tools passing scrutiny
- Improve error handling and logging
- Add source attribution for imported tools

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-16 06:20:16 -04:00
co-authored by Claude Opus 4.5
parent 78025aac8e
commit 5e01a335c0
11 changed files with 1072 additions and 74 deletions
+194 -8
View File
@@ -57,7 +57,7 @@ ALLOWED_SORT = {
"/categories": {"name", "tool_count"},
}
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,64}$")
TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
OWNER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
RESERVED_SLUGS = {"official", "admin", "system", "api", "registry", "cmdforge"}
@@ -1018,10 +1018,12 @@ def create_app() -> Flask:
categories_payload = yaml.safe_load(categories_yaml.read_text(encoding="utf-8")) or {}
predefined_categories = (categories_payload or {}).get("categories", [])
# Get counts for all categories in the database
# Get counts for all categories in the database (filtered by visibility)
vis_filter, vis_params = build_visibility_filter()
counts = query_all(
g.db,
"SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools GROUP BY category",
f"SELECT category, COUNT(DISTINCT owner || '/' || name) AS total FROM tools WHERE 1=1 {vis_filter} GROUP BY category",
vis_params,
)
count_map = {row["category"]: row["total"] for row in counts}
@@ -1079,31 +1081,35 @@ def create_app() -> Flask:
category = request.args.get("category")
limit = min(int(request.args.get("limit", 100)), 500)
# Build visibility filter
vis_filter, vis_params = build_visibility_filter("tools")
# Build query - extract tags from JSON array and count occurrences
if category:
rows = query_all(
g.db,
"""
f"""
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
FROM tools, json_each(tools.tags) AS tag
WHERE tools.category = ?
WHERE tools.category = ? {vis_filter}
GROUP BY tag.value
ORDER BY count DESC
LIMIT ?
""",
(category, limit),
[category] + vis_params + [limit],
)
else:
rows = query_all(
g.db,
"""
f"""
SELECT tag.value AS name, COUNT(DISTINCT tools.owner || '/' || tools.name) AS count
FROM tools, json_each(tools.tags) AS tag
WHERE 1=1 {vis_filter}
GROUP BY tag.value
ORDER BY count DESC
LIMIT ?
""",
(limit,),
vis_params + [limit],
)
data = [{"name": row["name"], "count": row["count"]} for row in rows]
@@ -1986,6 +1992,90 @@ def create_app() -> Flask:
return jsonify({"data": {"status": "updated"}})
@app.route("/api/v1/me", methods=["POST"])
@require_token
def update_profile() -> Response:
"""Update current user's profile (POST version for web forms)."""
data = request.get_json() or {}
# Validate fields
display_name = data.get("display_name", "").strip()
bio = data.get("bio", "").strip() if data.get("bio") else None
website = data.get("website", "").strip() if data.get("website") else None
if display_name and len(display_name) > 100:
return error_response("VALIDATION_ERROR", "Display name too long (max 100)", 400)
if bio and len(bio) > 500:
return error_response("VALIDATION_ERROR", "Bio too long (max 500)", 400)
if website and len(website) > 200:
return error_response("VALIDATION_ERROR", "Website URL too long (max 200)", 400)
# Build update query
updates = []
params = []
if display_name:
updates.append("display_name = ?")
params.append(display_name)
if bio is not None:
updates.append("bio = ?")
params.append(bio)
if website is not None:
updates.append("website = ?")
params.append(website)
if not updates:
return error_response("VALIDATION_ERROR", "No valid fields to update", 400)
updates.append("updated_at = CURRENT_TIMESTAMP")
params.append(g.current_publisher["id"])
g.db.execute(
f"UPDATE publishers SET {', '.join(updates)} WHERE id = ?",
params,
)
g.db.commit()
return jsonify({"data": {"status": "updated"}})
@app.route("/api/v1/me/password", methods=["POST"])
@require_token
def change_password() -> Response:
"""Change current user's password."""
data = request.get_json() or {}
current_password = data.get("current_password", "")
new_password = data.get("new_password", "")
if not current_password or not new_password:
return error_response("VALIDATION_ERROR", "Current and new password required", 400)
if len(new_password) < 8:
return error_response("VALIDATION_ERROR", "New password must be at least 8 characters", 400)
# Verify current password
publisher = query_one(
g.db,
"SELECT password_hash FROM publishers WHERE id = ?",
[g.current_publisher["id"]],
)
if not publisher:
return error_response("NOT_FOUND", "Publisher not found", 404)
try:
password_hasher.verify(publisher["password_hash"], current_password)
except VerifyMismatchError:
return error_response("INVALID_PASSWORD", "Current password is incorrect", 400)
# Hash and save new password
new_hash = password_hasher.hash(new_password)
g.db.execute(
"UPDATE publishers SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
[new_hash, g.current_publisher["id"]],
)
g.db.commit()
return jsonify({"data": {"status": "password_changed"}})
@app.route("/api/v1/featured/tools", methods=["GET"])
def featured_tools() -> Response:
"""Get featured tools for homepage/landing."""
@@ -2309,6 +2399,14 @@ def create_app() -> Flask:
data = []
for row in rows:
# Parse scrutiny report if available
scrutiny_report = None
if row["scrutiny_report"]:
try:
scrutiny_report = json.loads(row["scrutiny_report"])
except (json.JSONDecodeError, TypeError):
pass
data.append({
"id": row["id"],
"owner": row["owner"],
@@ -2319,6 +2417,8 @@ def create_app() -> Flask:
"published_at": row["published_at"],
"publisher_name": row["publisher_name"],
"visibility": row["visibility"],
"scrutiny_status": row["scrutiny_status"],
"scrutiny_report": scrutiny_report,
})
return jsonify({
@@ -2388,6 +2488,92 @@ def create_app() -> Flask:
return jsonify({"data": {"status": "rejected", "tool_id": tool_id}})
@app.route("/api/v1/admin/scrutiny", methods=["GET"])
@require_moderator
def admin_scrutiny_audit() -> Response:
"""List all tools with their scrutiny status for audit purposes."""
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 50, type=int), 100)
offset = (page - 1) * per_page
# Filter options
scrutiny_filter = request.args.get("scrutiny_status") # approved, pending_review, pending
moderation_filter = request.args.get("moderation_status") # approved, pending, rejected
where_clauses = []
params = []
if scrutiny_filter:
where_clauses.append("t.scrutiny_status = ?")
params.append(scrutiny_filter)
if moderation_filter:
where_clauses.append("t.moderation_status = ?")
params.append(moderation_filter)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
rows = query_all(
g.db,
f"""
SELECT t.id, t.owner, t.name, t.version, t.description, t.category,
t.scrutiny_status, t.scrutiny_report, t.moderation_status,
t.moderation_note, t.published_at, p.display_name as publisher_name
FROM tools t
JOIN publishers p ON t.publisher_id = p.id
WHERE {where_sql}
ORDER BY t.published_at DESC
LIMIT ? OFFSET ?
""",
params + [per_page, offset],
)
count_row = query_one(
g.db,
f"SELECT COUNT(*) as total FROM tools t WHERE {where_sql}",
params,
)
total = count_row["total"] if count_row else 0
data = []
for row in rows:
scrutiny_report = None
if row["scrutiny_report"]:
try:
scrutiny_report = json.loads(row["scrutiny_report"])
except (json.JSONDecodeError, TypeError):
pass
data.append({
"id": row["id"],
"owner": row["owner"],
"name": row["name"],
"version": row["version"],
"description": row["description"],
"category": row["category"],
"published_at": row["published_at"],
"publisher_name": row["publisher_name"],
"scrutiny_status": row["scrutiny_status"],
"scrutiny_report": scrutiny_report,
"moderation_status": row["moderation_status"],
"moderation_note": row["moderation_note"],
})
# Also get summary stats
stats = query_all(
g.db,
"""
SELECT scrutiny_status, moderation_status, COUNT(*) as count
FROM tools
GROUP BY scrutiny_status, moderation_status
""",
)
return jsonify({
"data": data,
"meta": paginate(page, per_page, total),
"stats": [dict(s) for s in stats],
})
@app.route("/api/v1/admin/tools/<int:tool_id>/remove", methods=["POST"])
@require_moderator
def admin_remove_tool(tool_id: int) -> Response:
+3 -2
View File
@@ -499,12 +499,13 @@ def migrate_db(conn: sqlite3.Connection) -> None:
pass
# Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL
# This ensures existing tools remain visible after migration
# This ensures existing tools remain visible after migration (one-time migration)
# Note: Only applies to NULL, NOT to 'pending' - pending tools need manual review
try:
conn.execute("""
UPDATE tools
SET moderation_status = 'approved'
WHERE moderation_status IS NULL OR moderation_status = 'pending'
WHERE moderation_status IS NULL
""")
conn.commit()
except sqlite3.OperationalError: