Add app pairing feature for simplified registry connection

- Add pairing_requests table and hostname column to api_tokens
- Add pairing API endpoints: initiate, check, status, connected-apps
- Add cmdforge config connect <username> CLI command
- Rewrite tokens.html as Connected Apps with pairing flow
- Update TUI: Connect button when not authenticated, Publish when connected
- Add private sync option after save in TUI when connected
- Add visibility parameter to publish_tool in registry_client

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-14 01:10:12 -04:00
co-authored by Claude Opus 4.5
parent 3fe7b07c70
commit 367fac204b
7 changed files with 870 additions and 94 deletions
+163
View File
@@ -1466,6 +1466,169 @@ def create_app() -> Flask:
g.db.commit()
return jsonify({"data": {"revoked": True}})
# ─── App Pairing (Connect Flow) ──────────────────────────────────────────────
@app.route("/api/v1/pairing/initiate", methods=["POST"])
@require_token
def initiate_pairing() -> Response:
"""Initiate a pairing request from the website. Creates a pending pairing code."""
import secrets
# Clean up old expired pairing requests
g.db.execute(
"DELETE FROM pairing_requests WHERE expires_at < ? OR status = 'claimed'",
[datetime.utcnow().isoformat()],
)
g.db.commit()
# Cancel any existing pending requests for this user
g.db.execute(
"UPDATE pairing_requests SET status = 'cancelled' WHERE publisher_id = ? AND status = 'pending'",
[g.current_publisher["id"]],
)
# Generate pairing code and token
pairing_code = secrets.token_urlsafe(16)
token, token_hash = generate_token()
expires_at = (datetime.utcnow() + timedelta(minutes=5)).isoformat()
g.db.execute(
"""
INSERT INTO pairing_requests (publisher_id, pairing_code, token_hash, token_plain, expires_at)
VALUES (?, ?, ?, ?, ?)
""",
[g.current_publisher["id"], pairing_code, token_hash, token, expires_at],
)
g.db.commit()
return jsonify({
"data": {
"status": "pending",
"username": g.current_publisher["slug"],
"expires_in": 300,
}
})
@app.route("/api/v1/pairing/check/<username>", methods=["GET"])
def check_pairing(username: str) -> Response:
"""CLI polls this to check if pairing is ready. Returns token if ready."""
hostname = request.args.get("hostname", "Unknown Device")
# Find pending pairing for this username
row = query_one(
g.db,
"""
SELECT pr.*, p.slug
FROM pairing_requests pr
JOIN publishers p ON pr.publisher_id = p.id
WHERE p.slug = ? AND pr.status = 'pending' AND pr.expires_at > ?
ORDER BY pr.created_at DESC
LIMIT 1
""",
[username, datetime.utcnow().isoformat()],
)
if not row:
return jsonify({"data": {"status": "not_found"}})
# Claim the pairing
g.db.execute(
"""
UPDATE pairing_requests
SET status = 'claimed', hostname = ?, claimed_at = ?
WHERE id = ?
""",
[hostname, datetime.utcnow().isoformat(), row["id"]],
)
# Create the actual API token with hostname
g.db.execute(
"""
INSERT INTO api_tokens (publisher_id, token_hash, name, hostname, created_at)
VALUES (?, ?, ?, ?, ?)
""",
[row["publisher_id"], row["token_hash"], f"App: {hostname}", hostname, datetime.utcnow().isoformat()],
)
g.db.commit()
return jsonify({
"data": {
"status": "connected",
"token": row["token_plain"],
"username": username,
}
})
@app.route("/api/v1/pairing/status", methods=["GET"])
@require_token
def pairing_status() -> Response:
"""Check if there's an active pairing request for the current user."""
row = query_one(
g.db,
"""
SELECT status, expires_at, created_at
FROM pairing_requests
WHERE publisher_id = ? AND status = 'pending' AND expires_at > ?
ORDER BY created_at DESC
LIMIT 1
""",
[g.current_publisher["id"], datetime.utcnow().isoformat()],
)
if not row:
return jsonify({"data": {"status": "none"}})
return jsonify({
"data": {
"status": "pending",
"expires_at": row["expires_at"],
}
})
@app.route("/api/v1/connected-apps", methods=["GET"])
@require_token
def list_connected_apps() -> Response:
"""List connected apps (tokens with hostnames) for the current user."""
rows = query_all(
g.db,
"""
SELECT id, name, hostname, created_at, last_used_at
FROM api_tokens
WHERE publisher_id = ? AND revoked_at IS NULL
ORDER BY last_used_at DESC NULLS LAST, created_at DESC
""",
[g.current_publisher["id"]],
)
data = []
for row in rows:
data.append({
"id": row["id"],
"name": row["name"],
"hostname": row["hostname"] or "Unknown",
"created_at": row["created_at"],
"last_used_at": row["last_used_at"],
})
return jsonify({"data": data})
@app.route("/api/v1/connected-apps/<int:app_id>", methods=["DELETE"])
@require_token
def disconnect_app(app_id: int) -> Response:
"""Disconnect (revoke) an app."""
row = query_one(
g.db,
"SELECT id FROM api_tokens WHERE id = ? AND publisher_id = ?",
[app_id, g.current_publisher["id"]],
)
if not row:
return error_response("NOT_FOUND", "App not found", 404)
g.db.execute(
"UPDATE api_tokens SET revoked_at = ? WHERE id = ?",
[datetime.utcnow().isoformat(), app_id],
)
g.db.commit()
return jsonify({"data": {"disconnected": True}})
@app.route("/api/v1/tools", methods=["POST"])
@require_token
def publish_tool() -> Response:
+33
View File
@@ -208,6 +208,23 @@ CREATE TABLE IF NOT EXISTS audit_log (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- App pairing for simplified connection flow
CREATE TABLE IF NOT EXISTS pairing_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
publisher_id INTEGER NOT NULL REFERENCES publishers(id),
pairing_code TEXT UNIQUE NOT NULL,
token_hash TEXT,
token_plain TEXT,
hostname TEXT,
status TEXT DEFAULT 'pending',
expires_at TIMESTAMP NOT NULL,
claimed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pairing_code ON pairing_requests(pairing_code, status);
CREATE INDEX IF NOT EXISTS idx_pairing_publisher ON pairing_requests(publisher_id, status);
CREATE TABLE IF NOT EXISTS consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT,
@@ -359,6 +376,22 @@ def migrate_db(conn: sqlite3.Connection) -> None:
except sqlite3.OperationalError:
pass
# API tokens table migrations (for Connected Apps feature)
cursor = conn.execute("PRAGMA table_info(api_tokens)")
tokens_cols = {row[1] for row in cursor.fetchall()}
tokens_migrations = [
("hostname", "TEXT", "NULL"),
]
for col_name, col_type, default in tokens_migrations:
if col_name not in tokens_cols:
try:
conn.execute(f"ALTER TABLE api_tokens ADD COLUMN {col_name} {col_type} DEFAULT {default}")
conn.commit()
except sqlite3.OperationalError:
pass
# Grandfather existing tools: set moderation_status to 'approved' for tools that have NULL
# This ensures existing tools remain visible after migration
try: