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:
2026-06-17 14:08:28 +00:00
parent 6b5347103b
commit 0a554ebc32
71 changed files with 1868 additions and 527 deletions
+11 -1
View File
@@ -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(
+19
View File
@@ -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 []
+4 -4
View File
@@ -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.")],
-2
View File
@@ -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",
+39 -17
View File
@@ -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")
)
+27 -15
View File
@@ -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)
+36 -1
View File
@@ -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",
+1 -13
View File
@@ -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 = {}
+1 -95
View File
@@ -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 {})},
)
+1 -1
View File
@@ -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 ""
)
+13
View File
@@ -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(
+6 -1
View File
@@ -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"]
+2 -8
View File
@@ -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
-70
View File
@@ -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}.")
+1 -1
View File
@@ -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)
+2 -60
View File
@@ -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}",
+5 -1
View File
@@ -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 []:
+25 -2
View File
@@ -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",
+107
View File
@@ -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)
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
-7
View File
@@ -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;
+4 -2
View File
@@ -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."));
+13 -4
View File
@@ -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)}`;
}
+1 -3
View File
@@ -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>
+1
View File
@@ -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>
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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 '-' }}.
+200
View File
@@ -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/&lt;slug&gt;
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`
+15 -23
View File
@@ -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>
+1 -3
View File
@@ -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);">&middot; {{ author['role'] }}</span>
{% endif %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
<span style="font-size: 0.75rem; color: var(--text-muted); margin-left: 0.5rem;">&middot; {{ dt_ago(gist.created_at) if gist.get('created_at') else time_ago }}</span>
</div>
</div>
+1 -3
View File
@@ -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">&middot; {{ author['role'] }}</span>
{% endif %}
<span class="post-detail-level">&middot; 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>
-3
View File
@@ -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">
+1 -3
View File
@@ -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);">&middot; {{ author['role'] }}</span>
{% endif %}
<span style="font-size: 0.75rem; color: var(--text-muted);">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
</div>
</div>
+2 -1
View File
@@ -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
+6
View File
@@ -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):