forked from retoor/devplacepy
feat: restrict backup archive download to primary admin and hide admin-hidden projects from other admins
- Add `get_admin_uids()` and `get_primary_admin_uid()` to database.py for resolving the earliest-created admin - Modify `can_view_project()` in content.py so a project hidden by an admin is invisible to other admins (both web UI and REST API) - Update `_download_url()` and `_backup_payload()` in admin/backups.py to accept a `can_download` flag, gating the download endpoint with `is_primary_admin()` - Remove `role` from `_user_facts()` in docs_live.py to avoid leaking admin status in live docs - Update doc summaries in docs_api.py to reflect the new admin-visibility and backup-download semantics
This commit is contained in:
+11
-1
@@ -56,12 +56,22 @@ def is_owner(item: dict | None, user: dict | None) -> bool:
|
||||
return bool(item and user and item["user_uid"] == user["uid"])
|
||||
|
||||
|
||||
def _owner_is_admin(project: dict) -> bool:
|
||||
owner_uid = project.get("user_uid")
|
||||
owner = get_users_by_uids([owner_uid]).get(owner_uid) if owner_uid else None
|
||||
return is_admin(owner)
|
||||
|
||||
|
||||
def can_view_project(project: dict | None, user: dict | None) -> bool:
|
||||
if not project:
|
||||
return False
|
||||
if not project.get("is_private"):
|
||||
return True
|
||||
return is_owner(project, user) or is_admin(user)
|
||||
if is_owner(project, user):
|
||||
return True
|
||||
if not is_admin(user):
|
||||
return False
|
||||
return not _owner_is_admin(project)
|
||||
|
||||
|
||||
def canonical_redirect(
|
||||
|
||||
@@ -1221,6 +1221,25 @@ def get_users_by_uids(uids):
|
||||
return {u["uid"]: u for u in users.find(users.table.columns.uid.in_(unique))}
|
||||
|
||||
|
||||
def get_admin_uids():
|
||||
if "users" not in db.tables:
|
||||
return []
|
||||
rows = db.query("SELECT uid FROM users WHERE role = 'Admin'")
|
||||
return [row["uid"] for row in rows]
|
||||
|
||||
|
||||
def get_primary_admin_uid():
|
||||
if "users" not in db.tables:
|
||||
return None
|
||||
rows = list(
|
||||
db.query(
|
||||
"SELECT uid FROM users WHERE role = 'Admin' "
|
||||
"ORDER BY created_at ASC, id ASC LIMIT 1"
|
||||
)
|
||||
)
|
||||
return rows[0]["uid"] if rows else None
|
||||
|
||||
|
||||
def search_users_by_username(q, *, exclude_uid=None, limit=10):
|
||||
if not q or "users" not in db.tables:
|
||||
return []
|
||||
|
||||
@@ -1214,7 +1214,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/private",
|
||||
title="Set project visibility",
|
||||
summary="Mark a project you own private (only you and administrators can see it) or public. Send value=1 for private, value=0 for public.",
|
||||
summary="Mark a project you own private or public. Send value=1 for private, value=0 for public. A project you hide as a member stays visible to administrators; a project you hide as an administrator is visible only to you, not to other administrators (this also hides its files and any attached containers).",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
params=[
|
||||
@@ -2948,7 +2948,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
method="GET",
|
||||
path="/projects/{project_slug}/containers",
|
||||
title="Container manager page",
|
||||
summary="The admin per-project container manager UI (Dockerfiles, builds, instance creation).",
|
||||
summary="The admin per-project container manager UI (instance creation and lifecycle). Returns 404 for an administrator who is not the owner of an administrator-hidden project.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
params=[
|
||||
@@ -2967,7 +2967,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
method="GET",
|
||||
path="/admin/containers",
|
||||
title="Admin containers list",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page.",
|
||||
summary="The admin Containers section: every instance across all projects, each linking to its detail page. Instances attached to another administrator's hidden project are excluded, and per-instance actions return 404 for a non-owner administrator.",
|
||||
auth="admin",
|
||||
interactive=True,
|
||||
),
|
||||
@@ -4809,7 +4809,7 @@ four ways to sign requests.
|
||||
method="GET",
|
||||
path="/admin/backups/{uid}/download",
|
||||
title="Download backup",
|
||||
summary="Stream a completed backup archive as a tar.gz file.",
|
||||
summary="Stream a completed backup archive as a tar.gz file. Restricted to the primary administrator (the first user created with the Admin role); every other administrator receives 403 Forbidden.",
|
||||
auth="admin",
|
||||
encoding="none",
|
||||
params=[field("uid", "path", "string", True, "", "Backup uid.")],
|
||||
|
||||
@@ -53,7 +53,6 @@ def _user_facts(user: dict) -> dict:
|
||||
|
||||
return {
|
||||
"username": user.get("username", ""),
|
||||
"role": user.get("role", "Member"),
|
||||
"member_since": format_date(user.get("created_at", ""))
|
||||
if user.get("created_at")
|
||||
else "",
|
||||
@@ -153,7 +152,6 @@ def live_text(facts: dict) -> str:
|
||||
parts += [
|
||||
"your dashboard account stats",
|
||||
user["username"],
|
||||
user["role"],
|
||||
"member since",
|
||||
user["member_since"],
|
||||
"level",
|
||||
|
||||
@@ -16,7 +16,8 @@ from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.backup import store
|
||||
from devplacepy.services.devii.tasks.schedule import cron_next, next_run, now_utc, to_iso
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.utils import not_found, require_admin
|
||||
from devplacepy.utils import is_primary_admin, not_found, require_admin
|
||||
from fastapi import HTTPException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -24,17 +25,21 @@ router = APIRouter()
|
||||
RETENTION_EXTEND_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _download_url(row: dict) -> str | None:
|
||||
if row.get("status") == store.STATUS_DONE and row.get("local_path"):
|
||||
def _download_url(row: dict, can_download: bool) -> str | None:
|
||||
if (
|
||||
can_download
|
||||
and row.get("status") == store.STATUS_DONE
|
||||
and row.get("local_path")
|
||||
):
|
||||
return f"/admin/backups/{row['uid']}/download"
|
||||
return None
|
||||
|
||||
|
||||
def _backup_payload(row: dict) -> dict:
|
||||
def _backup_payload(row: dict, can_download: bool) -> dict:
|
||||
return {
|
||||
**row,
|
||||
"size_human": store.human_bytes(int(row.get("size_bytes") or 0)),
|
||||
"download_url": _download_url(row),
|
||||
"download_url": _download_url(row, can_download),
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +65,8 @@ def _targets() -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def _dashboard(request: Request) -> dict:
|
||||
backups = [_backup_payload(row) for row in store.list_backups()]
|
||||
def _dashboard(request: Request, can_download: bool) -> dict:
|
||||
backups = [_backup_payload(row, can_download) for row in store.list_backups()]
|
||||
schedules = store.list_schedules()
|
||||
return {
|
||||
"storage": store.compute_storage_stats(),
|
||||
@@ -70,13 +75,14 @@ def _dashboard(request: Request) -> dict:
|
||||
"targets": _targets(),
|
||||
"metrics": _metrics(backups),
|
||||
"generated_at": store.now_iso(),
|
||||
"can_download_backups": can_download,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/backups", response_class=HTMLResponse)
|
||||
async def admin_backups(request: Request):
|
||||
admin = require_admin(request)
|
||||
data = _dashboard(request)
|
||||
data = _dashboard(request, is_primary_admin(admin))
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -105,8 +111,8 @@ async def admin_backups(request: Request):
|
||||
|
||||
@router.get("/backups/data")
|
||||
async def admin_backups_data(request: Request):
|
||||
require_admin(request)
|
||||
data = _dashboard(request)
|
||||
admin = require_admin(request)
|
||||
data = _dashboard(request, is_primary_admin(admin))
|
||||
return JSONResponse(BackupDashboardOut.model_validate(data).model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -146,7 +152,7 @@ async def admin_backups_run(
|
||||
return action_result(request, "/admin/backups")
|
||||
|
||||
|
||||
def _job_payload(job: dict) -> dict:
|
||||
def _job_payload(job: dict, can_download: bool) -> dict:
|
||||
result = job.get("result", {})
|
||||
done = job.get("status") == queue.DONE
|
||||
return {
|
||||
@@ -157,7 +163,7 @@ def _job_payload(job: dict) -> dict:
|
||||
"backup_uid": result.get("backup_uid") if done else None,
|
||||
"download_url": (
|
||||
f"/admin/backups/{result.get('backup_uid')}/download"
|
||||
if done and result.get("backup_uid")
|
||||
if can_download and done and result.get("backup_uid")
|
||||
else None
|
||||
),
|
||||
"error": job.get("error") or None,
|
||||
@@ -171,12 +177,14 @@ def _job_payload(job: dict) -> dict:
|
||||
|
||||
@router.get("/backups/jobs/{uid}")
|
||||
async def admin_backups_job(request: Request, uid: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "backup":
|
||||
raise not_found("Backup job not found")
|
||||
return JSONResponse(
|
||||
BackupJobOut.model_validate(_job_payload(job)).model_dump(mode="json")
|
||||
BackupJobOut.model_validate(
|
||||
_job_payload(job, is_primary_admin(admin))
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@@ -320,7 +328,19 @@ async def admin_backups_schedule_delete(request: Request, uid: str):
|
||||
|
||||
@router.get("/backups/{uid}/download")
|
||||
async def admin_backups_download(request: Request, uid: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
if not is_primary_admin(admin):
|
||||
audit.record(
|
||||
request,
|
||||
"security.authz.denied",
|
||||
user=admin,
|
||||
result="denied",
|
||||
summary=f"non-primary admin {admin['username']} denied backup download",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the primary administrator may download backups",
|
||||
)
|
||||
row = store.get_backup(uid)
|
||||
if not row or row.get("status") != store.STATUS_DONE:
|
||||
raise not_found("Backup not available")
|
||||
@@ -360,10 +380,12 @@ async def admin_backups_delete(request: Request, uid: str):
|
||||
|
||||
@router.get("/backups/{uid}")
|
||||
async def admin_backups_detail(request: Request, uid: str):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
row = store.get_backup(uid)
|
||||
if not row:
|
||||
raise not_found("Backup not found")
|
||||
return JSONResponse(
|
||||
BackupOut.model_validate(_backup_payload(row)).model_dump(mode="json")
|
||||
BackupOut.model_validate(
|
||||
_backup_payload(row, is_primary_admin(admin))
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
search_users_by_username,
|
||||
)
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.models import ContainerAdminCreateForm, ContainerEditForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import (
|
||||
@@ -39,11 +40,13 @@ def _project_index(project_uids: set) -> dict:
|
||||
return {row["uid"]: row for row in rows}
|
||||
|
||||
|
||||
def _decorate(instances: list) -> list:
|
||||
def _decorate(instances: list, viewer: dict | None = None) -> list:
|
||||
index = _project_index({inst["project_uid"] for inst in instances})
|
||||
decorated = []
|
||||
for inst in instances:
|
||||
project = index.get(inst["project_uid"], {})
|
||||
if viewer is not None and project and not can_view_project(project, viewer):
|
||||
continue
|
||||
row = dict(inst)
|
||||
row["project_title"] = project.get("title", "")
|
||||
row["project_slug"] = project.get("slug") or project.get("uid") or ""
|
||||
@@ -63,6 +66,14 @@ def _project_of(inst: dict) -> dict:
|
||||
return get_table("projects").find_one(uid=inst["project_uid"]) or {}
|
||||
|
||||
|
||||
def _viewable_instance_or_404(uid: str, viewer: dict) -> dict:
|
||||
inst = _instance_or_404(uid)
|
||||
project = _project_of(inst)
|
||||
if project and not can_view_project(project, viewer):
|
||||
raise not_found("Instance not found")
|
||||
return inst
|
||||
|
||||
|
||||
def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summary: str, metadata=None) -> None:
|
||||
audit.record(
|
||||
request,
|
||||
@@ -80,7 +91,7 @@ def _audit_admin(request: Request, admin: dict, event_key: str, inst: dict, summ
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def containers_index(request: Request):
|
||||
admin = require_admin(request)
|
||||
instances = _decorate(store.all_instances())
|
||||
instances = _decorate(store.all_instances(), admin)
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
request,
|
||||
@@ -113,24 +124,25 @@ async def containers_index(request: Request):
|
||||
|
||||
@router.get("/data")
|
||||
async def containers_index_json(request: Request):
|
||||
require_admin(request)
|
||||
return JSONResponse({"instances": _decorate(store.all_instances())})
|
||||
admin = require_admin(request)
|
||||
return JSONResponse({"instances": _decorate(store.all_instances(), admin)})
|
||||
|
||||
|
||||
@router.get("/projects/search")
|
||||
async def project_search(request: Request, q: str = ""):
|
||||
require_admin(request)
|
||||
admin = require_admin(request)
|
||||
if not q or "projects" not in db.tables:
|
||||
return JSONResponse({"results": []})
|
||||
rows = db.query(
|
||||
"SELECT uid, slug, title FROM projects "
|
||||
"WHERE deleted_at IS NULL AND title LIKE :q LIMIT 10",
|
||||
"SELECT uid, slug, title, user_uid, is_private FROM projects "
|
||||
"WHERE deleted_at IS NULL AND title LIKE :q LIMIT 20",
|
||||
q=f"%{q}%",
|
||||
)
|
||||
results = [
|
||||
{"uid": r["uid"], "slug": r["slug"] or r["uid"], "title": r["title"]}
|
||||
for r in rows
|
||||
]
|
||||
if can_view_project(r, admin)
|
||||
][:10]
|
||||
return JSONResponse({"results": results})
|
||||
|
||||
|
||||
@@ -148,7 +160,7 @@ async def container_create(
|
||||
):
|
||||
admin = require_admin(request)
|
||||
project = resolve_by_slug(get_table("projects"), data.project_slug)
|
||||
if not project:
|
||||
if not project or not can_view_project(project, admin):
|
||||
return json_error(404, "project not found")
|
||||
try:
|
||||
inst = await api.create_instance(
|
||||
@@ -191,7 +203,7 @@ async def container_create(
|
||||
@router.get("/{uid}/edit", response_class=HTMLResponse)
|
||||
async def container_edit_page(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _instance_or_404(uid)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
project = _project_of(inst)
|
||||
run_as_user = None
|
||||
if inst.get("run_as_uid"):
|
||||
@@ -234,7 +246,7 @@ async def container_edit(
|
||||
request: Request, uid: str, data: Annotated[ContainerEditForm, Form()]
|
||||
):
|
||||
admin = require_admin(request)
|
||||
inst = _instance_or_404(uid)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
try:
|
||||
updated = api.update_instance_config(
|
||||
inst,
|
||||
@@ -283,7 +295,7 @@ _ACTIONS = {
|
||||
@router.post("/{uid}/restart")
|
||||
async def container_action(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _instance_or_404(uid)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
actor = ("user", admin["uid"])
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
if action == "restart":
|
||||
@@ -305,7 +317,7 @@ async def container_action(request: Request, uid: str):
|
||||
@router.post("/{uid}/sync")
|
||||
async def container_sync(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _instance_or_404(uid)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
try:
|
||||
counts = await api.sync_workspace(inst, admin)
|
||||
except ContainerError as exc:
|
||||
@@ -324,7 +336,7 @@ async def container_sync(request: Request, uid: str):
|
||||
@router.post("/{uid}/delete")
|
||||
async def container_delete(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _instance_or_404(uid)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
api.mark_for_removal(inst, actor=("user", admin["uid"]))
|
||||
_audit_admin(
|
||||
request,
|
||||
@@ -339,7 +351,7 @@ async def container_delete(request: Request, uid: str):
|
||||
@router.get("/{uid}", response_class=HTMLResponse)
|
||||
async def container_instance_page(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
inst = _instance_or_404(uid)
|
||||
inst = _viewable_instance_or_404(uid, admin)
|
||||
project = _project_of(inst)
|
||||
project_slug = project.get("slug") or project.get("uid") or ""
|
||||
base = site_url(request)
|
||||
|
||||
@@ -29,6 +29,33 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _seniority_key(u: dict) -> tuple[str, int]:
|
||||
return (u.get("created_at") or "", u.get("id") or 0)
|
||||
|
||||
|
||||
def _is_senior_admin(actor: dict, target: dict | None) -> bool:
|
||||
if not target or target.get("role") != "Admin":
|
||||
return False
|
||||
if target.get("uid") == actor.get("uid"):
|
||||
return False
|
||||
return _seniority_key(target) < _seniority_key(actor)
|
||||
|
||||
|
||||
def _deny_senior(request: Request, admin: dict, uid: str, target: dict, event_key: str):
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
user=admin,
|
||||
result="denied",
|
||||
target_type="user",
|
||||
target_uid=uid,
|
||||
target_label=target.get("username"),
|
||||
summary=f"admin {admin['username']} cannot manage senior admin {target.get('username')}",
|
||||
links=[audit.target("user", uid, target.get("username"))],
|
||||
)
|
||||
return action_result(request, "/admin/users")
|
||||
|
||||
|
||||
@router.get("/users/{uid}/ai-usage")
|
||||
async def admin_user_ai_usage(request: Request, uid: str, hours: int = 24):
|
||||
user = get_current_user(request)
|
||||
@@ -105,6 +132,8 @@ async def admin_user_role(
|
||||
return action_result(request, "/admin/users")
|
||||
users = get_table("users")
|
||||
target_user = users.find_one(uid=uid)
|
||||
if _is_senior_admin(admin, target_user):
|
||||
return _deny_senior(request, admin, uid, target_user, "admin.user.role.change")
|
||||
old_role = target_user.get("role") if target_user else None
|
||||
users.update({"uid": uid, "role": role}, ["uid"])
|
||||
clear_user_cache(uid)
|
||||
@@ -135,6 +164,8 @@ async def admin_user_password(
|
||||
admin = require_admin(request)
|
||||
users = get_table("users")
|
||||
target_user = users.find_one(uid=uid)
|
||||
if _is_senior_admin(admin, target_user):
|
||||
return _deny_senior(request, admin, uid, target_user, "admin.user.password.reset")
|
||||
users.update({"uid": uid, "password_hash": await hash_password_async(data.password)}, ["uid"])
|
||||
logger.info(f"Admin {admin['username']} changed password for user {uid}")
|
||||
audit.record(
|
||||
@@ -172,6 +203,8 @@ async def admin_user_toggle(request: Request, uid: str):
|
||||
return action_result(request, "/admin/users")
|
||||
users = get_table("users")
|
||||
user = users.find_one(uid=uid)
|
||||
if _is_senior_admin(admin, user):
|
||||
return _deny_senior(request, admin, uid, user, "admin.user.active.disable")
|
||||
if user:
|
||||
new_state = not user.get("is_active", True)
|
||||
users.update({"uid": uid, "is_active": new_state}, ["uid"])
|
||||
@@ -196,12 +229,14 @@ async def admin_user_toggle(request: Request, uid: str):
|
||||
@router.post("/users/{uid}/reset-ai-quota")
|
||||
async def admin_user_reset_ai_quota(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
target_user = get_table("users").find_one(uid=uid)
|
||||
if _is_senior_admin(admin, target_user):
|
||||
return _deny_senior(request, admin, uid, target_user, "admin.user.ai_quota.reset")
|
||||
devii = service_manager.get_service("devii")
|
||||
removed = devii.reset_quota("user", uid) if devii is not None else 0
|
||||
logger.info(
|
||||
f"Admin {admin['username']} reset AI quota for user {uid} ({removed} ledger rows)"
|
||||
)
|
||||
target_user = get_table("users").find_one(uid=uid)
|
||||
audit.record(
|
||||
request,
|
||||
"admin.user.ai_quota.reset",
|
||||
|
||||
@@ -9,7 +9,7 @@ from devplacepy.services.dbapi import policy
|
||||
from devplacepy.services.dbapi.policy import Caller, DbApiBadTable, DbApiDenied
|
||||
|
||||
OPERATORS = {"gte": ">=", "lte": "<=", "gt": ">", "lt": "<"}
|
||||
RESERVED = {"limit", "before", "search", "include_deleted", "key", "hard", "execute"}
|
||||
RESERVED = {"limit", "before", "search", "include_deleted", "key", "execute"}
|
||||
|
||||
|
||||
def require_dbapi_caller(request: Request) -> Caller:
|
||||
@@ -52,18 +52,6 @@ async def read_body(request: Request) -> dict:
|
||||
return {key: value for key, value in form.items()}
|
||||
|
||||
|
||||
def row_payload(body: dict) -> dict:
|
||||
if "values_json" in body:
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(body["values_json"])
|
||||
except Exception:
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {key: value for key, value in body.items() if key not in ("confirm", "values_json")}
|
||||
|
||||
|
||||
def parse_filters(request: Request) -> tuple[dict, dict]:
|
||||
filters: dict = {}
|
||||
comparisons: dict = {}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.schemas import DbMutationOut, DbRowOut, DbRowsOut
|
||||
from devplacepy.schemas import DbRowOut, DbRowsOut
|
||||
from devplacepy.services.dbapi import crud
|
||||
from devplacepy.services.dbapi.crud import DbApiError
|
||||
|
||||
@@ -11,18 +11,12 @@ from ._shared import (
|
||||
assert_table,
|
||||
error,
|
||||
parse_filters,
|
||||
read_body,
|
||||
require_dbapi_caller,
|
||||
row_payload,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _target_uid(row: dict, value: str) -> str:
|
||||
return (row or {}).get("uid") or str(value)
|
||||
|
||||
|
||||
@router.get("/{table}")
|
||||
async def dbapi_list(
|
||||
request: Request,
|
||||
@@ -62,91 +56,3 @@ async def dbapi_get(request: Request, table: str, key: str, value: str):
|
||||
if row is None:
|
||||
return error(404, "Row not found")
|
||||
return JSONResponse(DbRowOut(table=table, row=row).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/{table}")
|
||||
async def dbapi_insert(request: Request, table: str):
|
||||
caller = require_dbapi_caller(request)
|
||||
assert_table(table)
|
||||
body = await read_body(request)
|
||||
try:
|
||||
row = crud.insert_row(table, row_payload(body), caller.uid)
|
||||
except DbApiError as exc:
|
||||
return error(400, str(exc))
|
||||
_audit(request, "database.row.insert", table, _target_uid(row, ""), caller)
|
||||
return JSONResponse(
|
||||
DbMutationOut(table=table, ok=True, mode="insert", row=row).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{table}/{key}/{value}")
|
||||
async def dbapi_update(request: Request, table: str, key: str, value: str):
|
||||
caller = require_dbapi_caller(request)
|
||||
assert_table(table)
|
||||
body = await read_body(request)
|
||||
try:
|
||||
row = crud.update_row(table, key, value, row_payload(body), caller.uid)
|
||||
except DbApiError as exc:
|
||||
return error(400, str(exc))
|
||||
if row is None:
|
||||
return error(404, "Row not found")
|
||||
_audit(request, "database.row.update", table, _target_uid(row, value), caller)
|
||||
return JSONResponse(
|
||||
DbMutationOut(table=table, ok=True, mode="update", row=row).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{table}/{key}/{value}")
|
||||
async def dbapi_delete(
|
||||
request: Request, table: str, key: str, value: str, hard: bool = False
|
||||
):
|
||||
caller = require_dbapi_caller(request)
|
||||
assert_table(table)
|
||||
try:
|
||||
outcome = crud.delete_row(table, key, value, caller.uid, hard=hard)
|
||||
except DbApiError as exc:
|
||||
return error(400, str(exc))
|
||||
if outcome is None:
|
||||
return error(404, "Row not found")
|
||||
_audit(
|
||||
request,
|
||||
"database.row.delete",
|
||||
table,
|
||||
_target_uid(outcome.get("row", {}), value),
|
||||
caller,
|
||||
metadata={"mode": outcome["mode"]},
|
||||
)
|
||||
return JSONResponse(
|
||||
DbMutationOut(
|
||||
table=table, ok=True, mode=outcome["mode"], row=outcome.get("row")
|
||||
).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{table}/{key}/{value}/restore")
|
||||
async def dbapi_restore(request: Request, table: str, key: str, value: str):
|
||||
caller = require_dbapi_caller(request)
|
||||
assert_table(table)
|
||||
try:
|
||||
row = crud.restore_row(table, key, value, caller.uid)
|
||||
except DbApiError as exc:
|
||||
return error(400, str(exc))
|
||||
if row is None:
|
||||
return error(404, "Row not found")
|
||||
_audit(request, "database.row.restore", table, _target_uid(row, value), caller)
|
||||
return JSONResponse(
|
||||
DbMutationOut(table=table, ok=True, mode="restore", row=row).model_dump(mode="json")
|
||||
)
|
||||
|
||||
|
||||
def _audit(request, event_key, table, target_uid, caller, metadata=None):
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
target_type=table,
|
||||
target_uid=target_uid,
|
||||
summary=f"{caller.username or caller.kind} {event_key} on {table}/{target_uid}",
|
||||
metadata={"table": table, "caller": caller.kind, **(metadata or {})},
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ async def dbapi_query(request: Request):
|
||||
if not verdict.valid:
|
||||
status = 409 if not verdict.is_select else 400
|
||||
hint = (
|
||||
" Use the structured /dbapi/{table} CRUD routes to change data."
|
||||
" The database API is read-only; data cannot be changed through it."
|
||||
if not verdict.is_select
|
||||
else ""
|
||||
)
|
||||
|
||||
@@ -51,6 +51,12 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "getting-started-vibing",
|
||||
"title": "Get started with vibing",
|
||||
"kind": "prose",
|
||||
"section": SECTION_GENERAL,
|
||||
},
|
||||
{
|
||||
"slug": "feed",
|
||||
"title": "The feed",
|
||||
@@ -308,6 +314,13 @@ DOCS_PAGES = [
|
||||
for page in api_doc_pages()
|
||||
],
|
||||
# Administration - operational guides (admins only)
|
||||
{
|
||||
"slug": "devii-admin",
|
||||
"title": "Devii for admins",
|
||||
"kind": "prose",
|
||||
"admin": True,
|
||||
"section": SECTION_ADMIN,
|
||||
},
|
||||
{
|
||||
"slug": "media-moderation",
|
||||
"title": "Media moderation",
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.responses import json_error
|
||||
from devplacepy.utils import not_found
|
||||
from devplacepy.services.containers import store
|
||||
@@ -38,10 +39,12 @@ def audit_instance(
|
||||
)
|
||||
|
||||
|
||||
def project_for(project_slug: str) -> dict:
|
||||
def project_for(project_slug: str, user: dict | None = None) -> dict:
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project:
|
||||
raise not_found("Project not found")
|
||||
if user is not None and not can_view_project(project, user):
|
||||
raise not_found("Project not found")
|
||||
return project
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from fastapi import APIRouter, Form, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.database import get_table, resolve_by_slug
|
||||
from devplacepy.content import can_view_project
|
||||
from devplacepy.models import ContainerExecForm, ContainerInstanceForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import ContainersOut
|
||||
@@ -43,7 +44,7 @@ router = APIRouter()
|
||||
@router.get("/{project_slug}/containers", response_class=HTMLResponse)
|
||||
async def containers_page(request: Request, project_slug: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
slug = slug_of(project)
|
||||
return respond(
|
||||
request,
|
||||
@@ -74,8 +75,8 @@ async def containers_page(request: Request, project_slug: str):
|
||||
|
||||
@router.get("/{project_slug}/containers/data")
|
||||
async def containers_json(request: Request, project_slug: str):
|
||||
require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
return JSONResponse(
|
||||
{
|
||||
"instances": store.list_instances(project["uid"]),
|
||||
@@ -91,7 +92,7 @@ async def create_instance(
|
||||
request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Form()]
|
||||
):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
try:
|
||||
inst = await api.create_instance(
|
||||
project,
|
||||
@@ -126,8 +127,8 @@ async def create_instance(
|
||||
|
||||
@router.get("/{project_slug}/containers/instances/{uid}")
|
||||
async def instance_detail(request: Request, project_slug: str, uid: str):
|
||||
require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -151,7 +152,7 @@ _ACTIONS = {
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/delete")
|
||||
async def delete_instance(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
api.mark_for_removal(inst, actor=("user", user["uid"]))
|
||||
audit_instance(
|
||||
@@ -171,7 +172,7 @@ async def instance_exec(
|
||||
data: Annotated[ContainerExecForm, Form()],
|
||||
):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
if not inst.get("container_id"):
|
||||
return json_error(400, "instance is not running")
|
||||
@@ -195,8 +196,8 @@ async def instance_exec(
|
||||
|
||||
@router.get("/{project_slug}/containers/instances/{uid}/logs")
|
||||
async def instance_logs(request: Request, project_slug: str, uid: str, tail: int = 200):
|
||||
require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
if not inst.get("container_id"):
|
||||
return JSONResponse({"logs": ""})
|
||||
@@ -213,7 +214,9 @@ async def instance_logs(request: Request, project_slug: str, uid: str, tail: int
|
||||
|
||||
@router.get("/{project_slug}/containers/instances/{uid}/metrics")
|
||||
async def instance_metrics(request: Request, project_slug: str, uid: str):
|
||||
require_admin(request)
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug, user)
|
||||
instance_for(project, uid)
|
||||
return JSONResponse(
|
||||
{"metrics": store.recent_metrics(uid), "stats": api.instance_stats(uid)}
|
||||
)
|
||||
@@ -222,7 +225,7 @@ async def instance_metrics(request: Request, project_slug: str, uid: str):
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/sync")
|
||||
async def instance_sync(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
try:
|
||||
counts = await api.sync_workspace(inst, user)
|
||||
@@ -245,7 +248,7 @@ async def instance_sync(request: Request, project_slug: str, uid: str):
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/restart")
|
||||
async def instance_action(request: Request, project_slug: str, uid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
actor = ("user", user["uid"])
|
||||
action = request.url.path.rsplit("/", 1)[-1]
|
||||
@@ -275,10 +278,12 @@ async def instance_exec_ws(websocket: WebSocket, project_slug: str, uid: str):
|
||||
await websocket.close(code=1013)
|
||||
return
|
||||
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||
if not project or not can_view_project(project, user):
|
||||
await websocket.close(code=1011)
|
||||
return
|
||||
inst = store.get_instance(uid)
|
||||
if (
|
||||
not project
|
||||
or not inst
|
||||
not inst
|
||||
or inst["project_uid"] != project["uid"]
|
||||
or not inst.get("container_id")
|
||||
):
|
||||
|
||||
@@ -31,7 +31,7 @@ async def create_schedule(
|
||||
data: Annotated[ContainerScheduleForm, Form()],
|
||||
):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
try:
|
||||
schedule = Schedule(
|
||||
@@ -64,7 +64,7 @@ async def create_schedule(
|
||||
@router.post("/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete")
|
||||
async def delete_schedule(request: Request, project_slug: str, uid: str, sid: str):
|
||||
user = require_admin(request)
|
||||
project = project_for(project_slug)
|
||||
project = project_for(project_slug, user)
|
||||
inst = instance_for(project, uid)
|
||||
store.delete_schedule(sid)
|
||||
audit.record(
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
get_admin_uids,
|
||||
get_site_stats,
|
||||
get_user_votes,
|
||||
get_recent_comments_by_target_uids,
|
||||
@@ -81,10 +82,14 @@ def get_projects_list(
|
||||
search_match = text_search_clause(projects, search, ("title", "description"))
|
||||
if search_match is not None:
|
||||
clauses.append(search_match)
|
||||
if not is_admin(viewer) and "is_private" in columns:
|
||||
if "is_private" in columns:
|
||||
visible = or_(columns.is_private.is_(None), columns.is_private == 0)
|
||||
if viewer:
|
||||
visible = or_(visible, columns.user_uid == viewer["uid"])
|
||||
if is_admin(viewer):
|
||||
admin_uids = get_admin_uids()
|
||||
if admin_uids:
|
||||
visible = or_(visible, columns.user_uid.notin_(admin_uids))
|
||||
clauses.append(visible)
|
||||
|
||||
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
|
||||
|
||||
@@ -196,6 +196,7 @@ class BackupDashboardOut(_Out):
|
||||
targets: list[dict] = []
|
||||
metrics: dict = {}
|
||||
generated_at: Optional[str] = None
|
||||
can_download_backups: bool = False
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
@@ -265,7 +266,6 @@ class UserOut(_Out):
|
||||
uid: str = ""
|
||||
username: str = ""
|
||||
avatar_seed: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
git_link: Optional[str] = None
|
||||
@@ -578,6 +578,7 @@ class SavedItemOut(_Out):
|
||||
|
||||
|
||||
class AdminUserOut(UserOut):
|
||||
role: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
posts_count: Optional[int] = None
|
||||
@@ -1074,13 +1075,6 @@ class DbRowOut(_Out):
|
||||
row: Optional[dict] = None
|
||||
|
||||
|
||||
class DbMutationOut(_Out):
|
||||
table: str = ""
|
||||
ok: bool = True
|
||||
mode: Optional[str] = None
|
||||
row: Optional[dict] = None
|
||||
|
||||
|
||||
class DbQueryOut(_Out):
|
||||
sql: str = ""
|
||||
valid: bool = False
|
||||
|
||||
@@ -88,9 +88,6 @@ LLM_API_KEY = str(
|
||||
if not LLM_API_KEY:
|
||||
LLM_API_KEY = str(uuid.uuid4())
|
||||
|
||||
DEEPSEEK_ENDPOINT = "https://api.deepseek.com/chat/completions"
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
|
||||
_BOOT_DT = datetime.now().astimezone()
|
||||
BOOT_DATETIME = _BOOT_DT.isoformat()
|
||||
BOOT_DAY_NAME = _BOOT_DT.strftime("%A")
|
||||
@@ -1047,7 +1044,6 @@ def get_backends() -> list[Backend]:
|
||||
if _backends is None:
|
||||
_backends = [
|
||||
Backend("molodetz", LLM_ENDPOINT, MODEL, LLM_API_KEY, True),
|
||||
Backend("deepseek", DEEPSEEK_ENDPOINT, DEEPSEEK_MODEL, os.getenv("DEEPSEEK_API_KEY"), False),
|
||||
]
|
||||
return _backends
|
||||
|
||||
|
||||
@@ -674,8 +674,6 @@ API_KEY = (
|
||||
or str(uuid.uuid4())
|
||||
)
|
||||
|
||||
DEEPSEEK_ENDPOINT = "https://api.deepseek.com/chat/completions"
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
|
||||
_BOOT_DT = datetime.now().astimezone()
|
||||
BOOT_DATETIME = _BOOT_DT.isoformat()
|
||||
@@ -932,7 +930,6 @@ def get_backends() -> list[Backend]:
|
||||
if _backends is None:
|
||||
_backends = [
|
||||
Backend("molodetz", LLM_ENDPOINT, MODEL, API_KEY, True),
|
||||
Backend("deepseek", DEEPSEEK_ENDPOINT, DEEPSEEK_MODEL, os.getenv("DEEPSEEK_API_KEY"), False),
|
||||
]
|
||||
return _backends
|
||||
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
purge,
|
||||
restore,
|
||||
soft_delete,
|
||||
text_search_clause,
|
||||
)
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
from .policy import soft_delete_aware
|
||||
|
||||
@@ -33,10 +27,6 @@ class DbApiError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def column_names(table_name: str) -> list[str]:
|
||||
return list(get_table(table_name).columns)
|
||||
|
||||
@@ -111,66 +101,6 @@ def get_row(table_name: str, key: str, value) -> dict | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def insert_row(table_name: str, data: dict, actor: str) -> dict:
|
||||
table = get_table(table_name)
|
||||
cols = set(column_names(table_name))
|
||||
row = dict(data or {})
|
||||
unknown = [key for key in row if key not in cols]
|
||||
if unknown:
|
||||
raise DbApiError(f"Unknown columns for {table_name}: {', '.join(sorted(unknown))}")
|
||||
if "uid" in cols and not row.get("uid"):
|
||||
row["uid"] = generate_uid()
|
||||
if "created_at" in cols and not row.get("created_at"):
|
||||
row["created_at"] = _now()
|
||||
if soft_delete_aware(table_name):
|
||||
row.setdefault("deleted_at", None)
|
||||
row.setdefault("deleted_by", None)
|
||||
table.insert(row)
|
||||
if row.get("uid"):
|
||||
return get_row(table_name, "uid", row["uid"]) or row
|
||||
return row
|
||||
|
||||
|
||||
def update_row(table_name: str, key: str, value, data: dict, actor: str) -> dict | None:
|
||||
_assert_key(table_name, key)
|
||||
table = get_table(table_name)
|
||||
cols = set(column_names(table_name))
|
||||
if not table.find_one(**{key: value}):
|
||||
return None
|
||||
payload = {k: v for k, v in (data or {}).items() if k not in ("id", "uid", key)}
|
||||
unknown = [k for k in payload if k not in cols]
|
||||
if unknown:
|
||||
raise DbApiError(f"Unknown columns for {table_name}: {', '.join(sorted(unknown))}")
|
||||
if "updated_at" in cols:
|
||||
payload["updated_at"] = _now()
|
||||
payload[key] = value
|
||||
table.update(payload, [key])
|
||||
return get_row(table_name, key, value)
|
||||
|
||||
|
||||
def delete_row(
|
||||
table_name: str, key: str, value, actor: str, *, hard: bool = False
|
||||
) -> dict | None:
|
||||
_assert_key(table_name, key)
|
||||
table = get_table(table_name)
|
||||
row = table.find_one(**{key: value})
|
||||
if not row:
|
||||
return None
|
||||
if soft_delete_aware(table_name) and not hard:
|
||||
soft_delete(table_name, actor, **{key: value})
|
||||
return {"mode": "soft", "row": dict(row)}
|
||||
purge(table_name, **{key: value})
|
||||
return {"mode": "hard", "row": dict(row)}
|
||||
|
||||
|
||||
def restore_row(table_name: str, key: str, value, actor: str) -> dict | None:
|
||||
_assert_key(table_name, key)
|
||||
if not soft_delete_aware(table_name):
|
||||
raise DbApiError(f"{table_name} does not support restore (no soft delete).")
|
||||
restore(table_name, **{key: value})
|
||||
return get_row(table_name, key, value)
|
||||
|
||||
|
||||
def _assert_key(table_name: str, key: str) -> None:
|
||||
if key not in set(column_names(table_name)):
|
||||
raise DbApiError(f"Unknown key column {key!r} for {table_name}.")
|
||||
|
||||
@@ -125,7 +125,7 @@ def validate_select(sql: str, dialect: str = "sqlite") -> Verdict:
|
||||
if not verdict.is_select:
|
||||
verdict.error = (
|
||||
f"Only SELECT queries run through query(); this is a {verdict.statement_type} "
|
||||
"statement. Use the structured /dbapi/{table} CRUD routes to change data."
|
||||
"statement. The database API is read-only and cannot change data."
|
||||
)
|
||||
return verdict
|
||||
ok, error = dry_run(verdict.sql)
|
||||
|
||||
@@ -1593,8 +1593,8 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Run a read-only SQL SELECT and return rows (admin only)",
|
||||
description=(
|
||||
"Executes a SINGLE validated SELECT statement read-only and returns the rows. Only "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected (use db_insert_row, "
|
||||
"db_update_row, db_delete_row for changes). The response may include a 'suspicious' "
|
||||
"SELECT is allowed; INSERT/UPDATE/DELETE/DDL are rejected. The database API is "
|
||||
"read-only and cannot change data in any way. The response may include a 'suspicious' "
|
||||
"list (e.g. a SELECT with no WHERE/JOIN/LIMIT that scans a whole table); when present, "
|
||||
"surface that warning to the user before trusting the results."
|
||||
),
|
||||
@@ -1638,64 +1638,6 @@ ACTIONS: tuple[Action, ...] = (
|
||||
requires_admin=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="db_insert_row",
|
||||
method="POST",
|
||||
path="/dbapi/{table}",
|
||||
summary="Insert a row into a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Inserts a new row. Pass the column values as a JSON object string in values_json. "
|
||||
"Soft-delete columns and uid/created_at are filled automatically. Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
body("values_json", "JSON object of column:value pairs for the new row.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_update_row",
|
||||
method="PATCH",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Update a row in a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Updates the row where key equals value. Pass the changed columns as a JSON object "
|
||||
"string in values_json. uid and id cannot be changed. Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
body("values_json", "JSON object of column:value pairs to change.", required=True),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="db_delete_row",
|
||||
method="DELETE",
|
||||
path="/dbapi/{table}/{key}/{value}",
|
||||
summary="Delete a row from a table (admin only, confirmation required)",
|
||||
description=(
|
||||
"Soft-deletes the row where key equals value (restorable). Pass hard=true to "
|
||||
"PERMANENTLY purge it (or for tables without soft delete). Requires confirmation."
|
||||
),
|
||||
params=(
|
||||
path("table", "Table name."),
|
||||
path("key", "Key column to match (usually 'uid')."),
|
||||
path("value", "Value of the key column."),
|
||||
Param(
|
||||
name="hard",
|
||||
location="query",
|
||||
description="Permanently purge instead of soft delete.",
|
||||
required=False,
|
||||
type="boolean",
|
||||
),
|
||||
confirm(),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="gateway_providers",
|
||||
method="GET",
|
||||
|
||||
@@ -53,9 +53,6 @@ CONFIRM_REQUIRED = {
|
||||
"backup_delete",
|
||||
"backup_schedule_delete",
|
||||
"notification_reset",
|
||||
"db_insert_row",
|
||||
"db_update_row",
|
||||
"db_delete_row",
|
||||
"gateway_provider_delete",
|
||||
"gateway_model_delete",
|
||||
}
|
||||
@@ -219,26 +216,6 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
|
||||
"explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_insert_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"This writes a new row directly into the '{table}' table. Show the user the exact "
|
||||
"table and values, get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_update_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"This updates an existing row in the '{table}' table directly. Show the user the "
|
||||
"exact row and new values, get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "db_delete_row":
|
||||
table = str(arguments.get("table", "")).strip() or "(unspecified)"
|
||||
hard = str(arguments.get("hard", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
kind = "PERMANENTLY purges" if hard else "soft-deletes"
|
||||
return ToolInputError(
|
||||
f"This {kind} a row in the '{table}' table. Show the user the exact row, get explicit "
|
||||
"confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name in CONFIRM_REQUIRED:
|
||||
return ToolInputError(
|
||||
"This removes the item as a soft delete: it disappears from every surface and is only "
|
||||
@@ -279,7 +256,7 @@ class Dispatcher:
|
||||
self._docs = DocsController(settings, is_admin=is_admin)
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
self._rsearch = RsearchController(settings, owner_kind, owner_id)
|
||||
from ..container import ContainerController
|
||||
|
||||
self._container = ContainerController(client)
|
||||
|
||||
@@ -36,9 +36,11 @@ class ContainerController:
|
||||
return {"uid": "admin", "username": username or "admin"}
|
||||
|
||||
def _project(self, arguments: dict) -> dict:
|
||||
from devplacepy.content import can_view_project
|
||||
|
||||
slug = str(arguments.get("project_slug", "")).strip()
|
||||
project = resolve_by_slug(get_table("projects"), slug) if slug else None
|
||||
if not project:
|
||||
if not project or not can_view_project(project, self._actor_user()):
|
||||
raise ToolInputError(f"project not found: {slug}")
|
||||
return project
|
||||
|
||||
|
||||
@@ -26,8 +26,12 @@ def _flag(value: Any) -> str:
|
||||
|
||||
|
||||
class RsearchController:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(
|
||||
self, settings: Settings, owner_kind: str = "guest", owner_id: str = ""
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if not self._settings.rsearch_enabled:
|
||||
@@ -44,6 +48,13 @@ class RsearchController:
|
||||
return await self._describe(arguments)
|
||||
raise ToolInputError(f"Unknown rsearch tool: {name}")
|
||||
|
||||
def _ledger(self, endpoint: str, success: bool, status_code: int) -> None:
|
||||
from devplacepy.services.openai_gateway.usage import record_rsearch_call
|
||||
|
||||
record_rsearch_call(
|
||||
self._owner_kind, self._owner_id, endpoint, success, status_code
|
||||
)
|
||||
|
||||
async def _request(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
timeout = httpx.Timeout(self._settings.rsearch_timeout_seconds, connect=30.0)
|
||||
@@ -56,9 +67,12 @@ class RsearchController:
|
||||
) as client:
|
||||
response = await client.get(path, params=params)
|
||||
except httpx.TimeoutException as exc:
|
||||
self._ledger(path, False, 0)
|
||||
raise NetworkError(f"rsearch timed out calling {path}", path=path) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
self._ledger(path, False, 0)
|
||||
raise NetworkError(f"rsearch request failed: {exc}", path=path) from exc
|
||||
self._ledger(path, response.status_code < 400, response.status_code)
|
||||
if response.status_code >= 400:
|
||||
raise UpstreamError(
|
||||
f"rsearch returned {response.status_code} for {path}",
|
||||
|
||||
@@ -71,7 +71,9 @@ def _strip_html(raw: str) -> tuple[str, str]:
|
||||
return title, body
|
||||
|
||||
|
||||
async def search_queries(queries: list[str]) -> list[dict]:
|
||||
async def search_queries(
|
||||
queries: list[str], emit: Callable[[dict], None] = lambda frame: None
|
||||
) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||
@@ -85,10 +87,12 @@ async def search_queries(queries: list[str]) -> list[dict]:
|
||||
"/search",
|
||||
params={"query": query, "count": RESULTS_PER_QUERY, "content": "false"},
|
||||
)
|
||||
emit({"type": "rsearch", "endpoint": "/search", "success": response.status_code < 400})
|
||||
if response.status_code >= 400:
|
||||
continue
|
||||
data = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
emit({"type": "rsearch", "endpoint": "/search", "success": False})
|
||||
logger.warning("deepsearch rsearch failed for %r: %s", query, exc)
|
||||
continue
|
||||
for item in data.get("results") or []:
|
||||
|
||||
@@ -62,7 +62,9 @@ class DeepsearchService(JobService):
|
||||
database.update_deepsearch_session(uid, {"status": "running"})
|
||||
|
||||
try:
|
||||
summary = await self._run_worker(uid, payload_path, output_dir)
|
||||
summary = await self._run_worker(
|
||||
uid, payload_path, output_dir, actor_kind, job.get("owner_id") or ""
|
||||
)
|
||||
except Exception as exc:
|
||||
hub.publish(uid, {"type": "failed", "message": str(exc)[:300]})
|
||||
database.update_deepsearch_session(uid, {"status": "failed"})
|
||||
@@ -161,7 +163,26 @@ class DeepsearchService(JobService):
|
||||
int(entry.get("byte_size") or 0),
|
||||
)
|
||||
|
||||
async def _run_worker(self, uid: str, payload_path: Path, output_dir: Path) -> dict:
|
||||
def _ledger_rsearch(self, owner_kind: str, owner_id: str, frame: dict) -> None:
|
||||
from devplacepy.services.openai_gateway.usage import record_rsearch_call
|
||||
|
||||
success = bool(frame.get("success"))
|
||||
record_rsearch_call(
|
||||
owner_kind,
|
||||
owner_id,
|
||||
frame.get("endpoint") or "/search",
|
||||
success,
|
||||
200 if success else 0,
|
||||
)
|
||||
|
||||
async def _run_worker(
|
||||
self,
|
||||
uid: str,
|
||||
payload_path: Path,
|
||||
output_dir: Path,
|
||||
owner_kind: str = "system",
|
||||
owner_id: str = "",
|
||||
) -> dict:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -186,6 +207,8 @@ class DeepsearchService(JobService):
|
||||
hub.publish(uid, frame)
|
||||
if frame.get("type") == "report_ready":
|
||||
summary = frame
|
||||
elif frame.get("type") == "rsearch":
|
||||
self._ledger_rsearch(owner_kind, owner_id, frame)
|
||||
elif frame.get("type") == "error":
|
||||
worker_error = frame.get("message", "worker error")
|
||||
err = (await proc.stderr.read()).decode("utf-8", "replace")
|
||||
|
||||
@@ -96,7 +96,7 @@ async def _run(payload: dict, output_dir: Path) -> dict:
|
||||
_emit({"type": "queries", "queries": queries})
|
||||
|
||||
_emit({"type": "stage", "stage": "searching", "message": "Searching the web"})
|
||||
candidates = await search_queries(queries)
|
||||
candidates = await search_queries(queries, _emit)
|
||||
_emit({"type": "candidates", "count": len(candidates)})
|
||||
|
||||
_emit({"type": "stage", "stage": "crawling", "message": "Crawling sources"})
|
||||
|
||||
@@ -130,9 +130,13 @@ def build_analytics(
|
||||
) -> dict:
|
||||
if GATEWAY_LEDGER not in db.tables:
|
||||
return empty_payload(hours)
|
||||
hours = max(1, min(hours, MAX_WINDOW_HOURS))
|
||||
now = _now()
|
||||
cutoff = _iso(now - timedelta(hours=hours))
|
||||
if hours <= 0:
|
||||
hours = 0
|
||||
cutoff = "0000-01-01T00:00:00+00:00"
|
||||
else:
|
||||
hours = max(1, min(hours, MAX_WINDOW_HOURS))
|
||||
cutoff = _iso(now - timedelta(hours=hours))
|
||||
rows = _ledger_rows(cutoff)
|
||||
if not rows:
|
||||
return empty_payload(hours)
|
||||
|
||||
@@ -27,6 +27,7 @@ PRICE_OUTPUT_PER_M_DEFAULT = 0.28
|
||||
VISION_PRICE_INPUT_PER_M_DEFAULT = 0.0
|
||||
VISION_PRICE_OUTPUT_PER_M_DEFAULT = 0.0
|
||||
EMBED_PRICE_INPUT_PER_M_DEFAULT = 0.01
|
||||
RSEARCH_COST_PER_CALL_DEFAULT = 0.0
|
||||
|
||||
USAGE_RETENTION_HOURS_DEFAULT = 720
|
||||
|
||||
|
||||
@@ -267,6 +267,15 @@ class GatewayService(BaseService):
|
||||
help="Fallback only; used when the embeddings upstream returns no native cost.",
|
||||
group="Pricing",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_rsearch_cost_per_call",
|
||||
"rsearch cost / call ($)",
|
||||
type="float",
|
||||
default=config.RSEARCH_COST_PER_CALL_DEFAULT,
|
||||
minimum=0,
|
||||
help="Flat cost attributed to each external rsearch call (web search / AI answer / chat / image describe), recorded under backend 'rsearch' so external AI spend appears in AI usage.",
|
||||
group="Pricing",
|
||||
),
|
||||
ConfigField(
|
||||
"gateway_max_retries",
|
||||
"Max retries",
|
||||
|
||||
@@ -313,6 +313,90 @@ class GatewayUsageLedger:
|
||||
logger.warning("gateway usage record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def record_external(
|
||||
self,
|
||||
*,
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
backend: str,
|
||||
endpoint: str,
|
||||
model: str,
|
||||
cost_usd: float,
|
||||
success: bool,
|
||||
status_code: int,
|
||||
latency_ms: float = 0.0,
|
||||
) -> Optional[dict]:
|
||||
try:
|
||||
row = {
|
||||
"created_at": _iso(_now()),
|
||||
"owner_kind": owner_kind or "unknown",
|
||||
"owner_id": owner_id or "unknown",
|
||||
"backend": backend,
|
||||
"endpoint": endpoint or "",
|
||||
"requested_model": model or "",
|
||||
"model": model or "",
|
||||
"status_code": int(status_code or 0),
|
||||
"success": 1 if success else 0,
|
||||
"error_category": None,
|
||||
"upstream_latency_ms": float(latency_ms or 0),
|
||||
"gateway_overhead_ms": 0.0,
|
||||
"queue_wait_ms": 0.0,
|
||||
"connect_ms": 0.0,
|
||||
"total_latency_ms": float(latency_ms or 0),
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cache_hit_tokens": 0,
|
||||
"cache_miss_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"tokens_per_second": 0.0,
|
||||
"context_window": None,
|
||||
"context_utilization": None,
|
||||
"cost_usd": round(float(cost_usd or 0), 8),
|
||||
"input_cost_usd": 0.0,
|
||||
"output_cost_usd": 0.0,
|
||||
"native_cost": 0,
|
||||
"stream_requested": 0,
|
||||
"temperature": None,
|
||||
"top_p": None,
|
||||
"max_tokens": None,
|
||||
"has_tools": 0,
|
||||
"retries_attempted": 0,
|
||||
"retry_succeeded": 0,
|
||||
"circuit_open": 0,
|
||||
"user_agent": "",
|
||||
}
|
||||
get_table(GATEWAY_LEDGER).insert(row)
|
||||
self._audit_external(row)
|
||||
return row
|
||||
except Exception as exc:
|
||||
logger.warning("gateway external usage record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def _audit_external(self, row: dict) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
owner_kind = row.get("owner_kind") or "unknown"
|
||||
owner_id = row.get("owner_id") or "unknown"
|
||||
actor_kind, actor_uid, actor_role = audit_actor_for(owner_kind, owner_id)
|
||||
audit.record_system(
|
||||
"ai.gateway.call",
|
||||
actor_kind=actor_kind,
|
||||
actor_uid=actor_uid,
|
||||
actor_role=actor_role,
|
||||
origin="api",
|
||||
result="success" if row.get("success") else "failure",
|
||||
summary=f"external AI call by {owner_kind}/{owner_id} ({row.get('backend')})",
|
||||
metadata={
|
||||
"backend": row.get("backend"),
|
||||
"endpoint": row.get("endpoint"),
|
||||
"cost_usd": row.get("cost_usd"),
|
||||
"status_code": row.get("status_code"),
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
},
|
||||
)
|
||||
|
||||
def _audit(self, raw: dict, norm: dict, cost_usd: float) -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
@@ -365,3 +449,26 @@ class GatewayUsageLedger:
|
||||
get_table(GATEWAY_CONCURRENCY).delete(created_at={"<": cutoff})
|
||||
)
|
||||
return ledger_removed, samples_removed
|
||||
|
||||
|
||||
def record_rsearch_call(
|
||||
owner_kind: str, owner_id: str, endpoint: str, success: bool, status_code: int
|
||||
) -> None:
|
||||
try:
|
||||
from devplacepy.services.manager import service_manager
|
||||
|
||||
service = service_manager.get_service("openai")
|
||||
cfg = service.get_config() if service is not None else {}
|
||||
cost = float(cfg.get("gateway_rsearch_cost_per_call", 0.0) or 0.0)
|
||||
GatewayUsageLedger().record_external(
|
||||
owner_kind=owner_kind or "system",
|
||||
owner_id=owner_id or "",
|
||||
backend="rsearch",
|
||||
endpoint=endpoint or "/search",
|
||||
model="rsearch",
|
||||
cost_usd=cost,
|
||||
success=success,
|
||||
status_code=status_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("rsearch usage ledger failed: %s", exc)
|
||||
|
||||
@@ -585,7 +585,7 @@ img {
|
||||
.topnav-user:hover { background: var(--bg-card); }
|
||||
.topnav-user-info { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.topnav-user-name { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
.topnav-user-level { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
.topnav-user-dropdown { position: relative; }
|
||||
.topnav-user-dropdown .dropdown-menu {
|
||||
display: none;
|
||||
@@ -724,7 +724,7 @@ img {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.topnav-mobile-user-role {
|
||||
.topnav-mobile-user-level {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.post-author-role {
|
||||
.post-author-level {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.post-detail-role {
|
||||
.post-detail-level {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
|
||||
@@ -36,13 +36,6 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.profile-role {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -18,7 +18,8 @@ class AiUsageMonitor {
|
||||
if (!this.root) return;
|
||||
if (this.windowSelect) {
|
||||
this.windowSelect.addEventListener("change", () => {
|
||||
this.hours = parseInt(this.windowSelect.value, 10) || 48;
|
||||
const parsed = parseInt(this.windowSelect.value, 10);
|
||||
this.hours = Number.isNaN(parsed) ? 48 : parsed;
|
||||
this.subscribe();
|
||||
this.poll();
|
||||
});
|
||||
@@ -132,7 +133,8 @@ class AiUsageMonitor {
|
||||
render(data) {
|
||||
this.root.textContent = "";
|
||||
if (this.generated && data.generated_at) {
|
||||
this.generated.textContent = `Window: ${data.window_hours}h - updated ${DateFormat.format(data.generated_at, true)}`;
|
||||
const windowLabel = data.window_hours ? `${data.window_hours}h` : "all time";
|
||||
this.generated.textContent = `Window: ${windowLabel} - updated ${DateFormat.format(data.generated_at, true)}`;
|
||||
}
|
||||
if (!data.requests) {
|
||||
this.root.appendChild(this.el("p", "admin-empty", "No AI gateway traffic recorded in this window yet."));
|
||||
|
||||
@@ -256,10 +256,18 @@ class BackupMonitor {
|
||||
|
||||
backupActions(backup) {
|
||||
const cell = this.el("td", "backups-actions");
|
||||
if (backup.download_url) {
|
||||
const link = this.el("a", "admin-btn admin-btn-sm", "Download");
|
||||
link.href = backup.download_url;
|
||||
cell.appendChild(link);
|
||||
if (backup.status === "done") {
|
||||
if (this.canDownload && backup.download_url) {
|
||||
const link = this.el("a", "admin-btn admin-btn-sm", "Download");
|
||||
link.href = backup.download_url;
|
||||
cell.appendChild(link);
|
||||
} else {
|
||||
const blocked = this.el("button", "admin-btn admin-btn-sm", "Download");
|
||||
blocked.disabled = true;
|
||||
blocked.setAttribute("aria-disabled", "true");
|
||||
blocked.title = "Not available";
|
||||
cell.appendChild(blocked);
|
||||
}
|
||||
}
|
||||
const remove = this.el("button", "admin-btn admin-btn-sm admin-btn-danger", "Delete");
|
||||
remove.dataset.action = "delete-backup";
|
||||
@@ -321,6 +329,7 @@ class BackupMonitor {
|
||||
}
|
||||
|
||||
render(data) {
|
||||
this.canDownload = !!data.can_download_backups;
|
||||
if (this.generated && data.generated_at) {
|
||||
this.generated.textContent = `Updated ${DateFormat.format(data.generated_at, true)}`;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
{% set _user = _author %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<div class="post-author-wrap">
|
||||
{% set _user = _author %}{% set _class = "post-author-link" %}{% include "_user_link.html" %}
|
||||
{% if is_admin(user) and _author and _author.get('role') %}
|
||||
<span class="post-author-role">{{ _author['role'] }}</span>
|
||||
{% endif %}
|
||||
<span class="post-author-level">Level {{ _author.get('level', 1) if _author else 1 }}</span>
|
||||
</div>
|
||||
<span class="post-time">{% if _created %}{{ dt_ago(_created) }}{% else %}{{ _time }}{% endif %}</span>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<option value="24">24 hours</option>
|
||||
<option value="48" selected>48 hours</option>
|
||||
<option value="168">7 days</option>
|
||||
<option value="0">All time</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" id="ai-usage-divided"> Show breakdowns</label>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
<div class="topnav-user-info">
|
||||
<span class="topnav-user-name">{{ user['username'] }}</span>
|
||||
{% if is_admin(user) %}<span class="topnav-user-role">{{ user['role'] }}</span>{% endif %}
|
||||
<span class="topnav-user-level">Level {{ user.get('level', 1) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
@@ -150,7 +150,7 @@
|
||||
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
|
||||
<div class="topnav-mobile-user-info">
|
||||
<span>{{ user['username'] }}</span>
|
||||
{% if is_admin(user) %}<span class="topnav-mobile-user-role">{{ user['role'] }}</span>{% endif %}
|
||||
<span class="topnav-mobile-user-level">Level {{ user.get('level', 1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">👤</span> Profile</a>
|
||||
|
||||
@@ -6,7 +6,7 @@ Live data from this DevPlace instance. Everything here is searchable from the
|
||||
and [Posts, Comments, Projects, Gists & News](/docs/content.html) for the APIs behind this data.
|
||||
|
||||
{% if facts.user %}{% set u = facts.user %}
|
||||
## {{ u.username }} ({{ u.role }})
|
||||
## {{ u.username }}
|
||||
|
||||
Member since {{ u.member_since }}. Level {{ u.level }} ({{ u.xp }} XP), {{ u.stars }} stars, rank #{{ u.rank if u.rank else '-' }}.
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Devii for administrators
|
||||
|
||||
Devii is the in-platform AI assistant, but for an administrator it is also an operator. When you sign in and chat with Devii, it acts as **your own account** over the platform REST API, and because your admin status is resolved server-side, the full set of admin tools appears only for you. This page is a practical playbook: for each admin capability it gives the exact phrasing to say to Devii, when to reach for it, and which guard rails apply.
|
||||
|
||||
> Audience: administrators only.
|
||||
|
||||
This is the operational companion to the technical reference under [Tools and scopes](/docs/devii-tools.html) and [Security and limits](/docs/devii-security.html). Read those for how the catalog and confirmation machinery are built; read this for what to actually say.
|
||||
|
||||
## How Devii operates as an admin
|
||||
|
||||
- Devii authenticates every action with **your own API key**, so all of its work rolls up under your uid and is attributable to you.
|
||||
- Admin-only tools (analytics, settings, user management, backups, gateway routing, the read-only database API) are withheld from non-admin sessions and exposed only because the server confirmed you are an administrator. You never have to ask for elevated access.
|
||||
- **Every action Devii takes is audited** with `origin=devii`, so you can always reconstruct what it did and when.
|
||||
- **Financial figures are admin-only.** Members and guests see only a percentage of their daily AI quota; you see token counts, dollar cost, and projected spend.
|
||||
- Devii never invents routes. When unsure it searches the docs first, then calls a real, audited endpoint - the same one the web UI uses.
|
||||
|
||||
A useful first move in any session: ask Devii to confirm its footing.
|
||||
|
||||
```
|
||||
Confirm you are operating my admin account, and list the admin capabilities you have.
|
||||
```
|
||||
|
||||
## Moderation and batch spam removal
|
||||
|
||||
This is the headline admin power. As an administrator you may delete **any** user's content, not just your own: `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `delete_media`, and `delete_attachment`. Every delete is a **soft delete** (stamped `deleted_at` / `deleted_by`), so nothing is destroyed - removed items are restorable from the admin **Trash**.
|
||||
|
||||
Every delete tool is **confirmation-gated**. The first call is always refused; Devii must show you the exact target, and only after you agree does it re-issue the call with `confirm=true`. This is deliberate: it makes a blind or accidental mass-delete impossible.
|
||||
|
||||
When to use it: clearing spam, abuse, or policy-violating content across one or many authors.
|
||||
|
||||
```
|
||||
Find every post by spammer123 that mentions casino or crypto giveaways.
|
||||
Show me each one first, then delete them once I confirm.
|
||||
```
|
||||
|
||||
Devii will list the matches, wait for your go-ahead, then delete each post and report back. Because the deletes are soft, if it catches a false positive you can restore it from Trash. See [Soft delete and data retention](/docs/soft-delete.html).
|
||||
|
||||
## Finding the items to act on (read-only database)
|
||||
|
||||
Before you can act in bulk you usually need to find the rows. Devii has a **strictly read-only** database surface over every table: `db_list_tables`, `db_list_rows`, `db_query` (SELECT-only - inserts, updates, deletes, and DDL are rejected), and `db_design_query` (natural language to a validated SELECT, which automatically adds `deleted_at IS NULL` for soft-delete tables).
|
||||
|
||||
When to use it: scoping a moderation sweep, investigating a user, or answering a data question. The database API can never modify data, so this step is always safe.
|
||||
|
||||
```
|
||||
Show me all comments from guest accounts created in the last 24 hours
|
||||
whose content looks like spam, newest first.
|
||||
```
|
||||
|
||||
```
|
||||
How many posts has each of the ten most recent signups made?
|
||||
```
|
||||
|
||||
Devii designs the SELECT, runs it read-only, and shows you both the SQL and the rows. You then decide what to delete - the find and the act are separate steps, by design.
|
||||
|
||||
## Repeatable macros and scheduled cleanups
|
||||
|
||||
Two features turn one-off moderation into standing operations.
|
||||
|
||||
**Virtual tools** are admin macros you define in plain language once and reuse. Devii stores them per-owner and adds them to its own tool list.
|
||||
|
||||
```
|
||||
Create a tool called purge_spam that takes a username and deletes all of that
|
||||
user's posts and comments after showing me the list.
|
||||
```
|
||||
|
||||
Later you simply say `purge_spam bob_test` and Devii runs the stored procedure (still honouring the per-delete confirmation).
|
||||
|
||||
**Scheduled tasks** run an autonomous prompt on a schedule (once, on an interval, or by cron). Use them for unattended hygiene.
|
||||
|
||||
```
|
||||
Every night at 3am, find zero-engagement spam posts older than 30 days
|
||||
and delete them. Log what you removed.
|
||||
```
|
||||
|
||||
Devii creates a cron task; the scheduler fires it on the lock-owner worker and records each run.
|
||||
|
||||
## User and access management
|
||||
|
||||
Manage members and roles conversationally: `admin_list_users`, `admin_set_user_role`, `admin_set_user_password`, and `admin_toggle_user` (enable or disable an account).
|
||||
|
||||
```
|
||||
List the newest 20 users and show me anyone who is currently disabled.
|
||||
```
|
||||
|
||||
```
|
||||
Make alice_test an admin.
|
||||
```
|
||||
|
||||
Guard rail - the **admin seniority guard**: a junior administrator cannot manage a more senior administrator (the earlier-registered admin wins). This is enforced server-side, so it applies to Devii exactly as it does to the admin web UI - Devii cannot talk its way around it.
|
||||
|
||||
## AI usage, cost, and quotas
|
||||
|
||||
Watch and control AI spend: `ai_usage` (gateway-wide metrics - volume, tokens, latency, cost, projected monthly burn, cache savings), `admin_user_ai_usage` (one user's spend), `cost_stats` (this Devii session's own cost), and the quota resets `admin_reset_user_ai_quota`, `admin_reset_guest_ai_quota`, and `admin_reset_all_ai_quota` (all three confirmation-gated).
|
||||
|
||||
```
|
||||
Which users burned the most tokens this week? Then reset the daily quota
|
||||
for the one who hit their limit.
|
||||
```
|
||||
|
||||
```
|
||||
What is our projected monthly AI cost at the current rate, and how much
|
||||
are we saving from prompt caching?
|
||||
```
|
||||
|
||||
## Audit and incident response
|
||||
|
||||
The audit trail is your forensic record: `audit_log` filters every state-changing action by `event_key`, `category`, `actor_role`, `actor_uid`, `origin` (web / api / devii / cli / service / scheduler), `result` (success / failure / denied), free text, and date range; `audit_event` opens one event with its full related-object links.
|
||||
|
||||
```
|
||||
Show me every content deletion in the last 7 days and who performed it.
|
||||
```
|
||||
|
||||
```
|
||||
List every denied admin action this month, then open the most recent one in detail.
|
||||
```
|
||||
|
||||
The audit log is read-only through Devii - it can be queried but never altered.
|
||||
|
||||
## Site settings and services
|
||||
|
||||
Read and change operational configuration with `admin_get_settings` and `admin_save_settings` (maintenance mode and message, registration open/closed, rate limits, session lengths, and more), and run the background services with `admin_list_services`, `admin_services_data` (live status, metrics, log tail), `admin_start_service`, `admin_stop_service`, `admin_run_service`, `admin_clear_service_logs`, and `admin_config_service`.
|
||||
|
||||
```
|
||||
Put the site in maintenance mode with the message "Back in 15 minutes",
|
||||
then tell me when you have done it.
|
||||
```
|
||||
|
||||
```
|
||||
How is the news service doing? Show its last log lines and run it once now.
|
||||
```
|
||||
|
||||
Admins are never locked out by maintenance mode, so you can always reverse it:
|
||||
|
||||
```
|
||||
Take the site back out of maintenance mode.
|
||||
```
|
||||
|
||||
## News moderation
|
||||
|
||||
Curate the developer news feed: `admin_list_news`, `admin_toggle_news` (show or hide), `admin_publish_news`, `admin_landing_news` (feature on the landing page), and `admin_delete_news` (confirmation-gated).
|
||||
|
||||
```
|
||||
Show me the latest news drafts and publish the two best ones,
|
||||
then feature the top one on the landing page.
|
||||
```
|
||||
|
||||
## Backups
|
||||
|
||||
Manage backups end to end: `backups_overview` (storage usage, disk space, every archive, configured schedules), `backup_run` (kick off a `database`, `uploads`, `keys`, or `full` backup), `backup_status` (poll a running job), `backup_delete` (confirmation-gated, irreversible), and the recurring schedules `backup_schedule_create` and `backup_schedule_delete`.
|
||||
|
||||
```
|
||||
Run a full backup now and tell me when it finishes, with the checksum and size.
|
||||
```
|
||||
|
||||
```
|
||||
Create a daily backup schedule of the database that keeps the last 7 archives.
|
||||
```
|
||||
|
||||
Guard rail: **downloading a backup archive is restricted to the primary administrator** (the earliest-created admin). Any other admin is blocked from the download, by design - the rest of the backup tools are available to all admins. See [Backups](/docs/backups.html).
|
||||
|
||||
## Containers
|
||||
|
||||
Operate per-project container instances: `container_list_instances`, `container_create_instance`, `container_instance_action` (start, stop, restart, pause, resume, delete, sync), `container_configure_instance`, `container_logs`, `container_exec` (run a one-shot command in the workspace), `container_stats`, and `container_schedule` (start or stop on a once / interval / cron schedule). You can also pop a live floating terminal with `open_terminal`.
|
||||
|
||||
```
|
||||
List the containers on the api project and restart any that have been running
|
||||
longer than four hours.
|
||||
```
|
||||
|
||||
```
|
||||
Attach a terminal to the web container.
|
||||
```
|
||||
|
||||
Guard rail: `container_exec` runs inside the container workspace only, and a command matching the **destructive pattern** (`rm`, `dd`, `mkfs`, `drop table`, `delete from`, `git clean`, redirect over a file, and similar) is confirmation-gated just like the delete tools. Instance delete is confirmation-gated too.
|
||||
|
||||
## Gateway model routing
|
||||
|
||||
Inspect and shape AI provider routing: `gateway_providers` and `gateway_models` (read the configured upstreams and source-to-target model routes with their pricing), and the editors `gateway_provider_set`, `gateway_provider_delete`, `gateway_model_set`, and `gateway_model_delete` (the two deletes are confirmation-gated).
|
||||
|
||||
```
|
||||
Show me the current model routes and their per-million-token pricing.
|
||||
```
|
||||
|
||||
```
|
||||
Route the molodetz model to the openrouter provider with the model and prices I give you.
|
||||
```
|
||||
|
||||
## Guard rails recap
|
||||
|
||||
Devii is powerful for admins but it is fenced:
|
||||
|
||||
- **Everything destructive is confirmation-gated.** Deletes, quota resets, backup and schedule deletes, gateway deletes, instance delete, and destructive container commands are all refused on the first call; Devii must show you the target and re-issue with `confirm=true`.
|
||||
- **Deletes are soft and restorable** from the admin Trash - only garbage collection is permanent.
|
||||
- **The database API is strictly read-only.** Devii can find anything but can never write through it.
|
||||
- **Seniority and primary-admin limits hold through Devii** exactly as in the web UI: no managing a more senior admin, and only the primary administrator downloads backups.
|
||||
- **Every action is audited** with `origin=devii`.
|
||||
|
||||
For the underlying machinery see [Tools and scopes](/docs/devii-tools.html), [Security and limits](/docs/devii-security.html), the read-only [Database API service](/docs/services-dbapi.html), the [Audit Log](/docs/audit-log.html), [Backups](/docs/backups.html), and [Soft delete and data retention](/docs/soft-delete.html).
|
||||
</div>
|
||||
@@ -0,0 +1,363 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Get started with vibing
|
||||
|
||||
> **Alpha, admin-only.** Vibe coding is a preview feature. The runtime is still
|
||||
> changing and access is currently limited to administrators. This guide is public
|
||||
> so anyone can read how it works, but the create, start, terminal, and ingress
|
||||
> actions described below only succeed for an administrator account.
|
||||
|
||||
**Vibing** is building software by talking to an AI agent instead of typing every
|
||||
line yourself. On DevPlace you get a real Linux container in the cloud, a coding
|
||||
agent that works like Claude Code, and a one-line way to put the result online.
|
||||
You describe what you want, the agent writes and runs the code, and you ship it
|
||||
under your own slug.
|
||||
|
||||
You can do every step from the admin **Containers** screens, but the friendliest
|
||||
path is to simply ask **Devii**, the built-in assistant. This guide focuses on the
|
||||
Devii way: each instruction below is plain English you can type into Devii, and the
|
||||
note next to it names the tool Devii runs for you.
|
||||
|
||||
## What you get
|
||||
|
||||
- A **project** to hold your files (the persistent storage for your work).
|
||||
- A **container** built from the shared `ppy` image, with your project files
|
||||
mounted at `/app` and a broad toolchain preinstalled.
|
||||
- Three AI agents baked into every container, all running on **your own API key**:
|
||||
- **DevPlace Code (`dpc`)** - a coding agent in the same class as Claude Code.
|
||||
- **`botje.py`** - a plug-and-play DevPlace bot you can copy and customise.
|
||||
- **`pagent`** - a minimal, zero-dependency agent for small scripted tasks.
|
||||
- **Ingress**: publish a port from your container to a public URL at `/p/<slug>`.
|
||||
|
||||
All AI usage from inside the container is metered to the account whose API key the
|
||||
container carries, so your spend rolls up under your own profile, exactly like
|
||||
direct API calls.
|
||||
|
||||
## The four steps, the Devii way
|
||||
|
||||
Open Devii from the user menu (the **Devii** item) and type these in order. Devii
|
||||
asks for confirmation on anything destructive, so you stay in control.
|
||||
|
||||
**1. Create a project (storage).**
|
||||
|
||||
> "Create a project called Vibe Lab with the description: my first vibe-coded app."
|
||||
|
||||
Devii calls `create_project`. The project is the home for every file your container
|
||||
produces.
|
||||
|
||||
**2. Attach a container to it.**
|
||||
|
||||
> "In Vibe Lab, create a container named lab that maps port 8000."
|
||||
|
||||
Devii calls `container_create_instance`. The instance runs the shared `ppy` image
|
||||
with your project mounted at `/app`. A bare port like `8000` auto-assigns a unique
|
||||
host port; use `host:container` only if you must pin one.
|
||||
|
||||
**3. Start the container.**
|
||||
|
||||
> "Start the lab container in Vibe Lab."
|
||||
|
||||
Devii calls `container_instance_action` with `action=start`. (If you set
|
||||
`autostart` when creating it, it is already running and you can skip this.)
|
||||
|
||||
**4. Open a terminal.**
|
||||
|
||||
> "Open a terminal in the lab container."
|
||||
|
||||
Devii calls the `open_terminal` action, which opens a floating xterm.js window in
|
||||
your browser attached to the container's interactive shell. From here you run
|
||||
`dpc`, `botje.py`, or anything else.
|
||||
|
||||
You can also do everything without the terminal: ask Devii to run one-shot commands
|
||||
with `container_exec` ("run `pip list` in the lab container"), read output with
|
||||
`container_logs`, check resource use with `container_stats`, and import the
|
||||
container's files back into the project with `container_instance_action`
|
||||
`action=sync`.
|
||||
|
||||
## Inside the container
|
||||
|
||||
- The OS user is always **`pravda`** (uid 1000). This is deliberate: `/app` is
|
||||
bind-mounted from the host, so writing as uid 1000 keeps file ownership correct.
|
||||
- Your working directory is **`/app`**, which is your project's files. Anything you
|
||||
create there can be synced back into the project.
|
||||
- **`apt` and `sudo` work without real root.** `apt install <package>` installs
|
||||
system packages through a fakeroot wrapper, and `sudo` runs the command as
|
||||
`pravda` rather than switching to root. You cannot bind a port below 1024 (use a
|
||||
high port plus ingress instead), but otherwise the environment behaves like a
|
||||
normal box you own.
|
||||
- Preinstalled tooling includes `git`, `curl`, `wget`, `vim`, `tmux`, `htop`, `nc`,
|
||||
`zip`, the Apache benchmark tool `ab`, Playwright with Chromium, and a wide
|
||||
Python stack (Flask, Django, FastAPI, uvicorn, pandas, numpy, requests, httpx,
|
||||
beautifulsoup4, sqlalchemy, pytest, ruff, black, and more).
|
||||
|
||||
## DevPlace Code (dpc)
|
||||
|
||||
`dpc` is **DevPlace Code**, a terminal coding agent that provides the same kind of
|
||||
experience as Claude Code: you give it a task, it reads and writes files, runs
|
||||
commands, fixes what it broke, and iterates until the job is done. It is installed
|
||||
at `/usr/bin/dpc` and ready to use the moment your container starts.
|
||||
|
||||
```bash
|
||||
dpc "build a small FastAPI app in app.py that serves a JSON health check at /"
|
||||
```
|
||||
|
||||
`dpc` reads your API key from the container environment (`PRAVDA_API_KEY`) and talks
|
||||
to the platform AI gateway, so **all of its AI usage is metered through your own
|
||||
account**. There is no separate key to manage and nothing to configure: it is plug
|
||||
and play.
|
||||
|
||||
## Container environment keys
|
||||
|
||||
Every container is launched with these variables already set. Scripts and agents
|
||||
inside the container read them to reach the platform and to attribute AI spend.
|
||||
|
||||
| Variable | What it contains |
|
||||
|----------|------------------|
|
||||
| `PRAVDA_BASE_URL` | The public base URL of this DevPlace instance. |
|
||||
| `PRAVDA_OPENAI_URL` | The AI gateway endpoint, `PRAVDA_BASE_URL` + `/openai/v1`. |
|
||||
| `PRAVDA_API_KEY` | The API key used for every AI call. Spend is metered to this account. |
|
||||
| `PRAVDA_USER_UID` | The DevPlace user id whose identity the container carries. |
|
||||
| `PRAVDA_CONTAINER_NAME` | The instance's name. |
|
||||
| `PRAVDA_CONTAINER_UID` | The instance's unique id. |
|
||||
| `PRAVDA_INGRESS_URL` | The public URL of this container when ingress is set, otherwise empty. |
|
||||
|
||||
The key that lands in `PRAVDA_API_KEY` is resolved in order from the instance's
|
||||
**run-as user**, then its creator, then the project owner. You can point a container
|
||||
at a specific account by asking Devii to set `run_as_uid` when creating or
|
||||
configuring it. The OS user stays `pravda`; only the identity and key change.
|
||||
|
||||
## botje.py - the plug-and-play bot
|
||||
|
||||
`botje.py` (installed at `/usr/bin/botje.py`) is a complete, ready-to-run DevPlace
|
||||
bot. Start it with no arguments and it logs in with your `PRAVDA_API_KEY`, then
|
||||
polls DevPlace for `@mentions` and direct messages and answers each one with a full
|
||||
agent toolset. Give it a task on the command line and it runs that single task and
|
||||
exits.
|
||||
|
||||
```bash
|
||||
python /usr/bin/botje.py # run as a DevPlace bot (polling loop)
|
||||
python /usr/bin/botje.py "summarise the latest news" # run one task and exit
|
||||
```
|
||||
|
||||
**What it can do.** Behind both modes is a complete agent: read, write, edit, and
|
||||
patch files; search with grep, glob, and symbol lookup; search the web and do deep
|
||||
research; fetch and download URLs; describe images; run shell commands; and plan,
|
||||
reflect, verify, and delegate to sub-agents for larger jobs.
|
||||
|
||||
**How it is configured.** Everything comes from the environment, so it is plug and
|
||||
play inside a container:
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `PRAVDA_API_KEY` | Auth for both DevPlace and the AI gateway (already set). |
|
||||
| `PRAVDA_BASE_URL` | Which DevPlace instance to talk to (already set). |
|
||||
| `BOT_USERNAME` | The bot's own username, so it ignores its own posts. |
|
||||
| `MENTION_POLL_SECONDS` | How often it checks for mentions (default 30). |
|
||||
| `DM_POLL_SECONDS` | How often it checks for direct messages (default 10). |
|
||||
| `DEVPLACE_MAX_ITERATIONS` | Upper bound on agent steps per task. |
|
||||
|
||||
**Make it your own.** `botje.py` is the reference bot, and it is meant to be
|
||||
forked. Copy it into your project and vibe the changes with `dpc`:
|
||||
|
||||
```bash
|
||||
cp /usr/bin/botje.py /app/mybot.py
|
||||
dpc "in mybot.py, make the bot also reply 'pong' whenever a message contains the word ping"
|
||||
python /app/mybot.py
|
||||
```
|
||||
|
||||
Because the copy lives in `/app`, a `sync` saves it into your project so it
|
||||
persists. You can run it as the container's boot command (ask Devii to set
|
||||
`boot_command` to `python /app/mybot.py`) and add a `restart_policy` so it stays up.
|
||||
|
||||
## Ingress: host your app at /p/<slug>
|
||||
|
||||
Ingress publishes one container port to a public URL on the platform. Once set, your
|
||||
app is reachable at `/p/<slug>` over both HTTP and WebSocket. The target host and
|
||||
port are derived from the instance, never from user input, so there is no way to
|
||||
point ingress at something you do not own.
|
||||
|
||||
Two values control it:
|
||||
|
||||
- **`ingress_slug`** - the public name. Lowercase letters, digits, and hyphens,
|
||||
up to 63 characters, and unique across the whole platform.
|
||||
- **`ingress_port`** - the container port to publish. It must be one of the ports
|
||||
you mapped on the instance. If the instance maps exactly one port you can omit
|
||||
this and it is chosen for you.
|
||||
|
||||
**Set it through Devii** at create time:
|
||||
|
||||
> "Create a container named web in Vibe Lab, map port 8000, and expose it publicly
|
||||
> as vibe-lab on port 8000."
|
||||
|
||||
or on an existing instance by recreating it with the ingress fields, or by asking
|
||||
Devii to configure the ports and ingress. The resulting URL is
|
||||
`PRAVDA_BASE_URL` + `/p/vibe-lab`, which is also placed in the container's
|
||||
`PRAVDA_INGRESS_URL` so your app can self-reference its own public address.
|
||||
|
||||
## Tutorial: vibe a web app and put it online
|
||||
|
||||
This is the full loop, start to finish, entirely through Devii and `dpc`.
|
||||
|
||||
**1. Create the project and an exposed container.** In Devii:
|
||||
|
||||
> "Create a project called Quote Wall. Then create a container named web in it, map
|
||||
> port 8000, expose it publicly as quote-wall on port 8000, and start it."
|
||||
|
||||
Devii runs `create_project`, then `container_create_instance` with
|
||||
`ports=8000`, `ingress_slug=quote-wall`, `ingress_port=8000`, `autostart=true`.
|
||||
|
||||
**2. Open a terminal.**
|
||||
|
||||
> "Open a terminal in the web container."
|
||||
|
||||
**3. Vibe the app with dpc.** In the terminal:
|
||||
|
||||
```bash
|
||||
dpc "create app.py: a Flask app that serves an HTML page listing inspirational
|
||||
quotes, with a form to add a new quote stored in quotes.json. Bind to
|
||||
0.0.0.0 port 8000. Then run it."
|
||||
```
|
||||
|
||||
`dpc` writes `app.py` and `quotes.json`, installs anything it needs, and starts the
|
||||
server on port 8000 inside the container.
|
||||
|
||||
**4. Visit your live app.** Open `PRAVDA_BASE_URL` + `/p/quote-wall` in your browser.
|
||||
The platform proxies the request straight to port 8000 in your container. Add a
|
||||
quote in the form and watch it persist.
|
||||
|
||||
**5. Keep it running and save the work.** Back in Devii:
|
||||
|
||||
> "Set the web container's boot command to `python /app/app.py`, set its restart
|
||||
> policy to unless-stopped, then sync it."
|
||||
|
||||
Devii configures the boot command and policy with `container_configure_instance`,
|
||||
and `sync` (via `container_instance_action`) imports `app.py` and `quotes.json` back
|
||||
into the Quote Wall project so they are saved. Your app now restarts on its own and
|
||||
its source lives in your project.
|
||||
|
||||
That is the whole vibe loop: describe, run, expose, save. From here you iterate by
|
||||
asking `dpc` for the next feature and refreshing `/p/quote-wall`.
|
||||
|
||||
## Tutorial: vibe a custom bot by changing botje
|
||||
|
||||
`botje.py` is the reference bot, and it is built to be changed. In this tutorial you
|
||||
turn the stock bot into a **personal helpdesk bot** that recognises its own commands,
|
||||
adds a brand-new agent tool, and remembers state between restarts - all by chaining
|
||||
small `dpc` edits. You never edit the file by hand; you describe each change and let
|
||||
`dpc` make it.
|
||||
|
||||
The pattern is the same every time:
|
||||
|
||||
1. Copy `botje.py` once into your project.
|
||||
2. Ask `dpc` for one focused change.
|
||||
3. Run the bot and try it from another account.
|
||||
4. Ask `dpc` for the next change.
|
||||
5. When it behaves, set it as the boot command and `sync` to save it.
|
||||
|
||||
**1. Start from a copy.** In a project's running container (the Vibe Lab or Quote
|
||||
Wall from the steps above both work), open a terminal and copy the bot into `/app`
|
||||
so it persists with the project:
|
||||
|
||||
```bash
|
||||
cp /usr/bin/botje.py /app/helpdesk.py
|
||||
```
|
||||
|
||||
**2. Add a custom command.** A command is just a phrase the bot recognises in a
|
||||
mention or DM. Ask `dpc` to add one:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, add a custom command: when a direct message starts with
|
||||
'!help', reply with a short list of the commands this bot supports. Keep the
|
||||
existing mention and DM behavior intact."
|
||||
```
|
||||
|
||||
`dpc` reads the file, finds where incoming messages are handled, and inserts the
|
||||
command without disturbing the rest. Run it and test from a second account:
|
||||
|
||||
```bash
|
||||
python /app/helpdesk.py
|
||||
```
|
||||
|
||||
DM the bot `!help` from another user and you should get the command list back.
|
||||
|
||||
**3. Give it a brand-new tool (the special functionality).** The bot answers with an
|
||||
agent that has a fixed toolset. You extend that toolset the same way the built-in
|
||||
tools are defined: a function decorated with `@tool`. Describe the tool you want and
|
||||
let `dpc` wire it in:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, add a new @tool called open_ticket(summary, priority) that
|
||||
appends a ticket as one JSON line to /app/tickets.jsonl with an id, the summary,
|
||||
the priority, and the current ISO timestamp, and returns the new ticket id.
|
||||
Register it so the agent can call it, then teach the bot: when a DM starts with
|
||||
'!ticket ', open a ticket from the rest of the message and reply with the id."
|
||||
```
|
||||
|
||||
Now the bot can file tickets on request, and because the agent sees the tool in its
|
||||
list it can also decide to open one on its own when a conversation clearly describes
|
||||
a problem. Restart and test:
|
||||
|
||||
```bash
|
||||
python /app/helpdesk.py
|
||||
```
|
||||
|
||||
DM `!ticket the login page is slow` and confirm a line lands in
|
||||
`/app/tickets.jsonl`.
|
||||
|
||||
**4. Add memory so it survives restarts.** State lives in plain files under `/app`,
|
||||
which is exactly what persists and syncs:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, add a !tickets command that reads /app/tickets.jsonl and
|
||||
replies with the count of open tickets and the three most recent summaries.
|
||||
Make the file read tolerant of it not existing yet."
|
||||
```
|
||||
|
||||
**5. Refine the voice.** Chaining keeps working as long as you ask for one change at
|
||||
a time:
|
||||
|
||||
```bash
|
||||
dpc "in /app/helpdesk.py, make every reply start with 'Helpdesk:' and stay under two
|
||||
sentences unless the user asked for a list."
|
||||
```
|
||||
|
||||
**6. Run it on boot and save it.** Once the bot behaves, hand it to the container
|
||||
service. In Devii:
|
||||
|
||||
> "Set this container's boot command to `python /app/helpdesk.py`, set its restart
|
||||
> policy to unless-stopped, then sync it."
|
||||
|
||||
Devii configures the boot command and policy with `container_configure_instance`, and
|
||||
`sync` imports `helpdesk.py` and `tickets.jsonl` back into the project so the whole
|
||||
bot is saved. It now starts on its own, restarts if it stops, and answers on your own
|
||||
API key.
|
||||
|
||||
**Where to take it next.** Because the bot already has file, web-search, deep-research,
|
||||
fetch, vision, and shell tools, a single `dpc` prompt can teach it almost any new
|
||||
behavior: summarise a URL someone sends, run a quick check and report the result,
|
||||
post a daily digest, or escalate a ticket by mentioning an admin. Add one tool or one
|
||||
command per prompt, test, and `sync`. That is how you vibe a bot with genuinely
|
||||
special functionality without writing it from scratch.
|
||||
|
||||
## Limits and safety
|
||||
|
||||
- The feature is in **Alpha** and **admin-only**. Behaviour and limits may change.
|
||||
- Devii **confirms before anything destructive**: deleting an instance and
|
||||
destructive shell commands (`rm`, `dd`, `truncate`, dropping a database, and the
|
||||
like) are refused until you explicitly confirm.
|
||||
- You cannot bind ports below 1024 inside the container. Use a high port and
|
||||
ingress to serve on the public web.
|
||||
- AI usage from `dpc`, `botje.py`, and `pagent` is metered to the API key the
|
||||
container carries. Keep an eye on your usage on your profile.
|
||||
|
||||
## Read next
|
||||
|
||||
- [Devii Assistant](/docs/devii.html) - everything the assistant can do for you.
|
||||
- [DeepSearch](/docs/tools-deepsearch.html) and [SEO Diagnostics](/docs/tools-seo.html) -
|
||||
the other tools you can drive conversationally.
|
||||
{% if is_admin(user) %}
|
||||
- [Container Manager](/docs/services-containers.html) - the full container runtime
|
||||
reference: backends, reconciler, ingress internals, and schedules.
|
||||
- [BotsService](/docs/services-bots.html) and [Bots internals](/docs/bots-internals.html) -
|
||||
how the autonomous bot fleet is built on the same agent.
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -9,6 +9,14 @@ building inside the app. A reconciling `BaseService` supervises the instances.
|
||||
> because running containers with bind mounts and access to the docker socket is root-equivalent on the
|
||||
> host.
|
||||
|
||||
> Visibility scoping: container access inherits the project's visibility through
|
||||
> `content.can_view_project`. A container attached to a project hidden by a **member** is visible to
|
||||
> every administrator, but a container attached to a project hidden by an **administrator** is visible
|
||||
> only to that owner administrator - every other administrator is blocked from listing it, opening a
|
||||
> terminal on it, exec-ing in it, or managing it, on both the web UI and the REST API. The opt-in public
|
||||
> ingress at `/p/{slug}` and the generic admin raw DB API `/dbapi` are deliberate exceptions and are not
|
||||
> scoped this way.
|
||||
|
||||
## Pieces
|
||||
|
||||
- **Backend abstraction** (`services/containers/backend/`): a `Backend` ABC with a `DockerCliBackend`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Database API service
|
||||
|
||||
The database API is a single, safe surface for reading and updating **every table** in the platform. It exposes generic CRUD per table, a validated read-only `query()`, a natural-language-to-SQL designer backed by the platform AI gateway, and asynchronous query execution streamed over a websocket. It is mounted at `/dbapi` and is reachable only by administrators or by internal services.
|
||||
The database API is a single, safe surface for **reading** every table in the platform. It exposes generic per-table reads, a validated read-only `query()`, a natural-language-to-SQL designer backed by the platform AI gateway, and asynchronous query execution streamed over a websocket. It is mounted at `/dbapi` and is reachable only by administrators or by internal services. The database API is **strictly read-only**: it can never insert, update, replace, delete, or restore data in any way.
|
||||
|
||||
> Audience: administrators, maintainers, and trusted internal services.
|
||||
|
||||
@@ -28,26 +28,19 @@ GET /dbapi/posts/schema
|
||||
{ "table": "posts", "columns": [ { "name": "uid", "type": "TEXT" }, ... ], "soft_delete": true, "row_count": 1240 }
|
||||
```
|
||||
|
||||
## CRUD per table
|
||||
## Reads per table
|
||||
|
||||
All writes go through the structured CRUD, which is parameterized, soft-delete aware, and never executes raw SQL. The endpoints are:
|
||||
The API exposes only two table-scoped read routes. There are no insert, update, delete, or restore endpoints, by design.
|
||||
|
||||
- `GET /dbapi/{table}` lists rows newest-first with keyset pagination. Filter with `?filter.<col>=value` (equality) or `?gte.<col>=`, `?lte.<col>=`, `?gt.<col>=`, `?lt.<col>=` (comparisons), full-text search common columns with `?search=`, page with `?before=<cursor>` and `?limit=` (max 500), and include soft-deleted rows with `?include_deleted=true`.
|
||||
- `GET /dbapi/{table}/{key}/{value}` returns one row where the key column equals the value (the key is usually `uid`).
|
||||
- `POST /dbapi/{table}` inserts a row. The body is the column map. Inserts are **born live**: `uid` and `created_at` are filled automatically when those columns exist, and soft-delete tables get `deleted_at: null` and `deleted_by: null` so the row is visible immediately. **Unknown columns are rejected**, so the API can never grow or pollute the schema.
|
||||
- `PATCH /dbapi/{table}/{key}/{value}` updates a row. `uid` and `id` can never be changed, and `updated_at` is set when present.
|
||||
- `DELETE /dbapi/{table}/{key}/{value}` removes a row. By default this is a **soft delete** (the row is stamped and disappears from normal reads but stays restorable from Trash). Pass `?hard=true` to permanently purge it, or for tables that have no soft-delete columns.
|
||||
- `POST /dbapi/{table}/{key}/{value}/restore` clears a soft delete.
|
||||
|
||||
Every mutation writes an audit event (`database.row.insert`, `.update`, `.delete`, `.restore`).
|
||||
|
||||
```
|
||||
POST /dbapi/bookmarks { "user_uid": "u1", "target_type": "post", "target_uid": "p1" }
|
||||
-> { "table": "bookmarks", "ok": true, "mode": "insert", "row": { "uid": "...", "deleted_at": null, ... } }
|
||||
GET /dbapi/bookmarks?filter.user_uid=u1&limit=20
|
||||
-> { "table": "bookmarks", "rows": [ ... ], "count": 7, "next_cursor": null }
|
||||
|
||||
DELETE /dbapi/bookmarks/uid/<uid> -> { "ok": true, "mode": "soft", ... }
|
||||
POST /dbapi/bookmarks/uid/<uid>/restore -> { "ok": true, "mode": "restore", ... }
|
||||
DELETE /dbapi/bookmarks/uid/<uid>?hard=true -> { "ok": true, "mode": "hard", ... }
|
||||
GET /dbapi/users/uid/<uid>
|
||||
-> { "table": "users", "row": { "uid": "...", "username": "...", ... } }
|
||||
```
|
||||
|
||||
## Read-only query()
|
||||
@@ -58,7 +51,7 @@ DELETE /dbapi/bookmarks/uid/<uid>?hard=true -> { "ok": true, "mode": "hard", ..
|
||||
2. **Flag suspicious shapes**: a `SELECT` with no `WHERE`, `JOIN`, or `LIMIT` (which scans an entire table), multiple statements, or `ATTACH`/`PRAGMA`/`VACUUM` style statements.
|
||||
3. **Dry run** the statement with `EXPLAIN` on a **separate read-only connection** (opened `mode=ro` with `PRAGMA query_only=ON`), which validates the SQL against the real schema without executing its body.
|
||||
|
||||
A non-`SELECT` returns `409 Conflict` with a hint to use the CRUD routes; an invalid `SELECT` returns `400`; a valid query returns the rows plus a `suspicious` list. Execution itself also happens on the read-only connection and is capped at `dbapi_max_rows`.
|
||||
A non-`SELECT` returns `409 Conflict` (the database API is read-only and cannot change data); an invalid `SELECT` returns `400`; a valid query returns the rows plus a `suspicious` list. Execution itself also happens on the read-only connection and is capped at `dbapi_max_rows`.
|
||||
|
||||
```
|
||||
POST /dbapi/query { "sql": "SELECT uid, username FROM users WHERE role = 'Admin' LIMIT 20" }
|
||||
@@ -71,7 +64,7 @@ POST /dbapi/query { "sql": "SELECT * FROM users" }
|
||||
-> 200 { "valid": true, "suspicious": ["SELECT has no WHERE, JOIN, or LIMIT and may return an entire table."], ... }
|
||||
```
|
||||
|
||||
Mutations are never possible through `query()`. To change data, use the structured CRUD routes above.
|
||||
Mutations are never possible through `query()`, or through any other part of the database API. The API cannot change data in any way.
|
||||
|
||||
## Ask in plain language
|
||||
|
||||
@@ -105,12 +98,11 @@ The job writes its result to the runtime data directory (`config.DBAPI_DIR/{uid}
|
||||
|
||||
## Devii
|
||||
|
||||
An administrator's Devii assistant exposes the same capability conversationally through admin-only tools:
|
||||
An administrator's Devii assistant exposes the same read-only capability conversationally through admin-only tools:
|
||||
|
||||
- Read: `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query` (SELECT only, and it surfaces any `suspicious` warnings), and `db_design_query` (natural language to SQL).
|
||||
- Write: `db_insert_row`, `db_update_row`, `db_delete_row`. Each is **confirmation gated**: the first call is refused and Devii must show the user exactly what will change and obtain explicit confirmation before calling again with `confirm=true`. The mutation tools pass arbitrary columns as a JSON object string in `values_json`.
|
||||
- `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query` (SELECT only, and it surfaces any `suspicious` warnings), and `db_design_query` (natural language to SQL).
|
||||
|
||||
This is how the rule "a non-SELECT is always confirmed" is honored: raw SQL stays read-only, and every write is a confirm-gated CRUD tool.
|
||||
There are no write tools. Devii cannot insert, update, or delete data through the database API.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -124,10 +116,10 @@ On `/admin/services` the **Database API** service (`dbquery`) exposes:
|
||||
|
||||
## Security summary
|
||||
|
||||
- The API is strictly read-only: there are no insert, update, delete, or restore routes or tools, so it can never change data.
|
||||
- One authorization boundary: administrator or internal key, enforced on every route, audited on denial.
|
||||
- Table allow/deny guard on every table-scoped path and on every table referenced by a query.
|
||||
- Raw SQL is always read-only, on a dedicated `query_only` connection, so even a validator miss cannot mutate.
|
||||
- CRUD rejects unknown columns, so the API never alters the schema.
|
||||
- Inserts are born live, deletes are soft and audited, reads exclude soft-deleted rows by default.
|
||||
- Devii read tools are admin-only; Devii write tools are admin-only and confirmation gated.
|
||||
- Reads exclude soft-deleted rows by default (opt in with `?include_deleted=true`).
|
||||
- All Devii database tools are admin-only and read-only.
|
||||
</div>
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||
{% if is_admin(user) and author and author.get('role') %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: 0.5rem;">· {{ dt_ago(gist.created_at) if gist.get('created_at') else time_ago }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
{% set _user = author %}{% set _size = 40 %}{% set _size_class = "md" %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
{% set _user = author %}{% set _class = "post-detail-author" %}{% include "_user_link.html" %}
|
||||
{% if is_admin(user) and author and author.get('role') %}
|
||||
<span class="post-detail-role">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
<span class="post-detail-level">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
||||
</div>
|
||||
<span class="post-detail-time">{{ dt_ago(post.created_at) if post.get('created_at') else time_ago }}</span>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
<img src="{{ avatar_url('multiavatar', profile_user['username'], 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview" loading="lazy">
|
||||
</div>
|
||||
<h1 class="profile-name">{{ profile_user['username'] }}</h1>
|
||||
{% if is_admin(user) and profile_user.get('role') %}
|
||||
<div class="profile-role">{{ profile_user['role'] }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="profile-stats">
|
||||
<div class="profile-stat">
|
||||
|
||||
@@ -43,9 +43,7 @@
|
||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||
<div>
|
||||
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||
{% if is_admin(user) and author and author.get('role') %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· {{ author['role'] }}</span>
|
||||
{% endif %}
|
||||
<span style="font-size: 0.75rem; color: var(--text-muted);">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from devplacepy.database import get_int_setting, get_setting, get_table
|
||||
from devplacepy.avatar import avatar_url
|
||||
from devplacepy.utils import format_date as _format_date
|
||||
from devplacepy.utils import time_ago as _time_ago
|
||||
from devplacepy.utils import get_badge, is_admin, pretty_json
|
||||
from devplacepy.utils import get_badge, is_admin, is_primary_admin, pretty_json
|
||||
from devplacepy.attachments import format_file_size, file_icon_emoji
|
||||
from devplacepy.content import is_owner as _owns
|
||||
from devplacepy.customization import custom_css_tag, custom_js_tag, page_type_for
|
||||
@@ -46,6 +46,7 @@ def static_url(path) -> str:
|
||||
templates.env.globals["static_url"] = static_url
|
||||
templates.env.globals["static_version"] = STATIC_VERSION
|
||||
templates.env.globals["is_admin"] = is_admin
|
||||
templates.env.globals["is_primary_admin"] = is_primary_admin
|
||||
templates.env.globals["owns"] = _owns
|
||||
templates.env.globals["is_self"] = is_self
|
||||
templates.env.globals["guest_disabled"] = guest_disabled
|
||||
|
||||
@@ -235,6 +235,12 @@ def is_admin(user: dict | None) -> bool:
|
||||
return bool(user) and user.get("role") == "Admin"
|
||||
|
||||
|
||||
def is_primary_admin(user: dict | None) -> bool:
|
||||
from devplacepy.database import get_primary_admin_uid
|
||||
|
||||
return is_admin(user) and user.get("uid") == get_primary_admin_uid()
|
||||
|
||||
|
||||
def require_admin(request: Request):
|
||||
user = require_user(request)
|
||||
if not is_admin(user):
|
||||
|
||||
Reference in New Issue
Block a user