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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user