Attribute Devii AI spend to its invoking action, fix quiz question-at-a-time review, DB API/isslop result routes, workspace docs, and drop redundant docstrings

- Route Devii-driven AI gateway cost to the action/tool that triggered
  it instead of a blanket "internal" bucket, so per-feature AI spend
  is attributable.
- Fix the quiz attempt review to show one previously-answered question
  at a time instead of all of them at once, and stop a quiz endpoint
  linked from the quiz flow from responding with raw JSON.
- Add DB API async query result route and AI Usage Analyzer annotated
  source/media routes, with traversal-safe uid/path handling and
  matching tests.
- Add Code Farm action audit logging (plant/harvest/buy-plot/upgrade/
  fertilize) and related admin workspace/services/trash/gateway route
  and doc touch-ups.
- Drop redundant docstrings from access_tokens.py per the no-comments
  convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VL9Xn57W5UR3HZbbuuzxdK
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent 9d7b3db314
commit 57087536e5
76 changed files with 805 additions and 338 deletions
+2 -1
View File
@@ -63,6 +63,7 @@ devplacepy/
| `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. | | `/` | Home page: marketing splash for guests, personalized home (welcome, feed shortcut, latest posts, news) for signed-in users. Does not redirect. Latest-posts section interleaves authors so no two consecutive posts share an author. |
| `/auth` | Signup, login, logout, forgot/reset password | | `/auth` | Signup, login, logout, forgot/reset password |
| `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. | | `/feed` | Post feed with topic/tab filtering and free-text `search` (title, content, and author username) in the left panel (public). Each page interleaves authors so no two consecutive posts share an author. |
| `/topics` | Crawlable per-topic category pages (public): `/topics` hub links every topic with a live post count, `/topics/{topic}` lists that topic's posts with its own canonical URL, title, and breadcrumbs |
| `/news` | Developer news listing, detail page with comments | | `/news` | Developer news listing, detail page with comments |
| `/posts` | Post detail, creation | | `/posts` | Post detail, creation |
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read | | `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
@@ -974,7 +975,7 @@ subscription is kept. APNs environment is stored per registration so a sandbox d
token and a production token can coexist. token and a production token can coexist.
A notification is also **marked read automatically when you open the page that shows its A notification is also **marked read automatically when you open the page that shows its
content** - viewing a post clears its comment, reply, upvote and mention notifications; content** - viewing a post clears its comment, reply, thread, upvote and mention notifications;
opening a conversation clears its direct-message notifications; visiting a profile clears opening a conversation clears its direct-message notifications; visiting a profile clears
the matching follow, badge and level notifications; and the issue, reminder and farm-raid the matching follow, badge and level notifications; and the issue, reminder and farm-raid
notifications clear on their respective pages. You no longer have to dismiss each one by notifications clear on their respective pages. You no longer have to dismiss each one by
+31 -2
View File
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
from PIL import Image from PIL import Image
from io import BytesIO from io import BytesIO
import httpx import httpx
from devplacepy import stealth from devplacepy.net_guard import BlockedAddressError, guarded_async_client
from devplacepy.database import get_table, db, get_setting from devplacepy.database import get_table, db, get_setting
from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR from devplacepy.config import UPLOADS_DIR, ATTACHMENTS_DIR
from devplacepy.utils import generate_uid from devplacepy.utils import generate_uid
@@ -389,7 +389,7 @@ async def fetch_remote_file(url, filename=None):
await _guard_public_url(url) await _guard_public_url(url)
max_bytes = _get_max_upload_bytes() max_bytes = _get_max_upload_bytes()
try: try:
async with stealth.stealth_async_client( async with guarded_async_client(
follow_redirects=True, follow_redirects=True,
timeout=REMOTE_FETCH_TIMEOUT, timeout=REMOTE_FETCH_TIMEOUT,
headers={"User-Agent": REMOTE_FETCH_USER_AGENT}, headers={"User-Agent": REMOTE_FETCH_USER_AGENT},
@@ -412,6 +412,8 @@ async def fetch_remote_file(url, filename=None):
413, 413,
) )
data = b"".join(chunks) data = b"".join(chunks)
except BlockedAddressError as exc:
raise RemoteFetchError(str(exc), 400) from exc
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise RemoteFetchError(f"Could not fetch {url}: {exc}", 400) from exc raise RemoteFetchError(f"Could not fetch {url}: {exc}", 400) from exc
@@ -453,6 +455,33 @@ def link_attachments(uids, target_type, target_uid):
) )
def split_attachment_uids(raw):
return [
uid.strip() for item in raw or [] for uid in str(item).split(",") if uid.strip()
]
def get_orphan_attachments_batch(uids, user, admin=False):
if not uids:
return []
placeholders = ",".join(f":p{i}" for i in range(len(uids)))
params = {f"p{i}": uid for i, uid in enumerate(uids)}
rows = db.query(
f"SELECT * FROM attachments WHERE uid IN ({placeholders}) AND deleted_at IS NULL",
**params,
)
by_uid = {row["uid"]: row for row in rows}
owned = []
for uid in uids:
row = by_uid.get(uid)
if not row or row.get("target_uid"):
continue
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
continue
owned.append(uid)
return owned
def set_gitea_asset_id(uid, asset_id): def set_gitea_asset_id(uid, asset_id):
get_table("attachments").update( get_table("attachments").update(
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"] {"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
+2 -1
View File
@@ -74,7 +74,7 @@ from .moderation import (
years_between, years_between,
) )
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics from .content import resolve_by_slug, resolve_object_url, get_projects_by_uids, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news, get_trending_topics
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_user_attachments, get_user_attachment, get_deleted_media
from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages from .stats import _stats_cache, get_site_stats, _analytics_cache, get_platform_analytics, _gist_languages_cache, get_gist_languages
from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification from .schema import BUG_TABLE_RENAMES, migrate_bug_tables_to_issue_tables, init_db, _refresh_query_planner_stats, OLD_GATEWAY_URL, migrate_ai_gateway_settings, backfill_api_keys, _backfill_gamification
@@ -296,6 +296,7 @@ __all__ = [
"load_comments_by_target_uids", "load_comments_by_target_uids",
"resolve_by_slug", "resolve_by_slug",
"resolve_object_url", "resolve_object_url",
"get_projects_by_uids",
"get_uids_by_username_match", "get_uids_by_username_match",
"text_search_clause", "text_search_clause",
"get_daily_topic", "get_daily_topic",
+11
View File
@@ -102,6 +102,17 @@ def resolve_object_url(target_type: str, target_uid: str) -> str:
return "/feed" return "/feed"
def get_projects_by_uids(uids):
if not uids or "projects" not in db.tables:
return {}
projects = get_table("projects")
if "uid" not in projects.columns:
return {}
seen = set()
unique = [u for u in uids if u not in seen and not seen.add(u)]
return {p["uid"]: p for p in projects.find(projects.table.columns.uid.in_(unique))}
def get_uids_by_username_match(search, limit=200): def get_uids_by_username_match(search, limit=200):
term = (search or "").strip() term = (search or "").strip()
if not term or "users" not in db.tables: if not term or "users" not in db.tables:
+39 -25
View File
@@ -4,7 +4,7 @@ import os
from .core import TTLCache, _in_clause, _now_iso, db, get_table from .core import TTLCache, _in_clause, _now_iso, db, get_table
from .users import get_users_by_uids from .users import get_users_by_uids
from .soft_delete import soft_delete, soft_delete_in from .soft_delete import soft_delete_in
VOTABLE_TARGETS: dict[str, str] = { VOTABLE_TARGETS: dict[str, str] = {
@@ -133,6 +133,26 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"]) get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
def _child_uids(table_name, parent_column, parent_uids, live_only=False):
if table_name not in db.tables:
return []
table = db[table_name]
if parent_column not in table.columns:
return []
clause = table.table.columns[parent_column].in_(parent_uids)
if live_only:
return [row["uid"] for row in table.find(clause, deleted_at=None)]
return [row["uid"] for row in table.find(clause)]
def _delete_in(table_name, column, uids):
placeholders, params = _in_clause(uids)
with db:
db.query(
f"DELETE FROM {table_name} WHERE {column} IN ({placeholders})", **params
)
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None: def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
uids = [uid for uid in (target_uids or []) if uid] uids = [uid for uid in (target_uids or []) if uid]
if not uids: if not uids:
@@ -145,21 +165,15 @@ def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str)
"bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type "bookmarks", "target_uid", uids, deleted_by, stamp=stamp, target_type=target_type
) )
if target_type == "post" and "polls" in db.tables: if target_type == "post" and "polls" in db.tables:
for uid in uids: poll_uids = _child_uids("polls", "post_uid", uids, live_only=True)
for poll in db["polls"].find(post_uid=uid, deleted_at=None): soft_delete_in("poll_votes", "poll_uid", poll_uids, deleted_by, stamp=stamp)
soft_delete("poll_votes", deleted_by, stamp=stamp, poll_uid=poll["uid"]) soft_delete_in("poll_options", "poll_uid", poll_uids, deleted_by, stamp=stamp)
soft_delete("poll_options", deleted_by, stamp=stamp, poll_uid=poll["uid"]) soft_delete_in("polls", "post_uid", uids, deleted_by, stamp=stamp)
soft_delete("polls", deleted_by, stamp=stamp, post_uid=uid)
if target_type == "post" and "opinion_wars" in db.tables: if target_type == "post" and "opinion_wars" in db.tables:
for uid in uids: war_uids = _child_uids("opinion_wars", "post_uid", uids, live_only=True)
for war in db["opinion_wars"].find(post_uid=uid, deleted_at=None): soft_delete_in("opinion_war_fighters", "war_uid", war_uids, deleted_by, stamp=stamp)
soft_delete( soft_delete_in("opinion_war_events", "war_uid", war_uids, deleted_by, stamp=stamp)
"opinion_war_fighters", deleted_by, stamp=stamp, war_uid=war["uid"] soft_delete_in("opinion_wars", "post_uid", uids, deleted_by, stamp=stamp)
)
soft_delete(
"opinion_war_events", deleted_by, stamp=stamp, war_uid=war["uid"]
)
soft_delete("opinion_wars", deleted_by, stamp=stamp, post_uid=uid)
def delete_engagement(target_type: str, target_uids: list) -> None: def delete_engagement(target_type: str, target_uids: list) -> None:
@@ -184,21 +198,21 @@ def delete_engagement(target_type: str, target_uids: list) -> None:
**params, **params,
) )
if target_type == "post" and "polls" in tables: if target_type == "post" and "polls" in tables:
for uid in uids: poll_uids = _child_uids("polls", "post_uid", uids)
for poll in db["polls"].find(post_uid=uid): if poll_uids:
if "poll_votes" in tables: if "poll_votes" in tables:
db["poll_votes"].delete(poll_uid=poll["uid"]) _delete_in("poll_votes", "poll_uid", poll_uids)
if "poll_options" in tables: if "poll_options" in tables:
db["poll_options"].delete(poll_uid=poll["uid"]) _delete_in("poll_options", "poll_uid", poll_uids)
db["polls"].delete(post_uid=uid) _delete_in("polls", "post_uid", uids)
if target_type == "post" and "opinion_wars" in tables: if target_type == "post" and "opinion_wars" in tables:
for uid in uids: war_uids = _child_uids("opinion_wars", "post_uid", uids)
for war in db["opinion_wars"].find(post_uid=uid): if war_uids:
if "opinion_war_fighters" in tables: if "opinion_war_fighters" in tables:
db["opinion_war_fighters"].delete(war_uid=war["uid"]) _delete_in("opinion_war_fighters", "war_uid", war_uids)
if "opinion_war_events" in tables: if "opinion_war_events" in tables:
db["opinion_war_events"].delete(war_uid=war["uid"]) _delete_in("opinion_war_events", "war_uid", war_uids)
db["opinion_wars"].delete(post_uid=uid) _delete_in("opinion_wars", "post_uid", uids)
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None: def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
-2
View File
@@ -15,8 +15,6 @@ def get_users_by_uids(uids):
_admins_cache = TTLCache(ttl=300, max_size=4) _admins_cache = TTLCache(ttl=300, max_size=4)
# The primary administrator must be an account that can actually authenticate, so scan a
# few of the earliest admins and skip any that are soft-deleted or deactivated.
PRIMARY_ADMIN_CANDIDATES = 50 PRIMARY_ADMIN_CANDIDATES = 50
-26
View File
@@ -1,9 +1,4 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
"""
Generic FastAPI dependency that accepts JSON or form-encoded data,
validated against a Pydantic model.
"""
import json import json
import logging import logging
from typing import Any, TypeVar, get_origin from typing import Any, TypeVar, get_origin
@@ -17,21 +12,10 @@ logger = logging.getLogger(__name__)
_TModel = TypeVar("_TModel", bound=BaseModel) _TModel = TypeVar("_TModel", bound=BaseModel)
# Container origins recognised as sequence fields that may receive
# multiple values from form data.
_SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset}) _SEQUENCE_ORIGINS = frozenset({list, set, tuple, frozenset})
def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]: def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
"""Convert FormData to a dict suitable for Pydantic validation.
* Sequence-typed model fields collect every submitted value via
``getlist()``; a lone empty string is dropped (browsers emit empty
hidden inputs by default).
* Scalar fields use ``get()`` (the last value).
* Fields absent from the form are omitted so that Pydantic applies
the model default.
"""
body: dict[str, Any] = {} body: dict[str, Any] = {}
for field_name, field_info in model.model_fields.items(): for field_name, field_info in model.model_fields.items():
origin = get_origin(field_info.annotation) origin = get_origin(field_info.annotation)
@@ -50,8 +34,6 @@ def _formdata_to_dict(form: FormData, model: type[BaseModel]) -> dict[str, Any]:
class _JsonOrForm: class _JsonOrForm:
"""Internal callable that parses JSON or form data and validates."""
def __init__(self, model: type[BaseModel]): def __init__(self, model: type[BaseModel]):
self.model = model self.model = model
@@ -70,7 +52,6 @@ class _JsonOrForm:
status_code=400, detail="JSON body must be an object" status_code=400, detail="JSON body must be an object"
) )
return self.model.model_validate(body) return self.model.model_validate(body)
# Default: form-encoded (multipart or url-encoded)
try: try:
form = await request.form() form = await request.form()
except Exception as exc: except Exception as exc:
@@ -85,11 +66,4 @@ class _JsonOrForm:
def json_or_form(model: type[_TModel]) -> _JsonOrForm: def json_or_form(model: type[_TModel]) -> _JsonOrForm:
"""Dependency factory: accept JSON or form-encoded data for a Pydantic model.
Usage:
@router.post("/create")
async def create(data: Annotated[PostForm, Depends(json_or_form(PostForm))]):
...
"""
return _JsonOrForm(model) return _JsonOrForm(model)
-8
View File
@@ -51,16 +51,8 @@ PUSH_TYPE = "alert"
PRIORITY = "10" PRIORITY = "10"
SHARED_TOKEN_KEY = "push_apns_shared_token" SHARED_TOKEN_KEY = "push_apns_shared_token"
# Per Apple's documented reason table, only these mean the TOKEN itself is
# permanently dead (410 Unregistered/ExpiredToken, 400 BadDeviceToken).
# DeviceTokenNotForTopic and TopicDisallowed are topic/provisioning
# misconfigurations at 400 - they affect every token uniformly and must
# never be treated as a reason to delete a registration.
DEAD_REASONS = frozenset({"BadDeviceToken", "ExpiredToken", "Unregistered"}) DEAD_REASONS = frozenset({"BadDeviceToken", "ExpiredToken", "Unregistered"})
# 403 reasons meaning the provider (JWT) token/credential itself was
# rejected, not any device token. The cached token must be dropped so the
# next attempt re-signs instead of retrying the same rejected token.
AUTH_REASONS = frozenset( AUTH_REASONS = frozenset(
{ {
"BadCertificate", "BadCertificate",
+1
View File
@@ -89,6 +89,7 @@ async def admin_backups(request: Request):
{"name": "Backups", "url": "/admin/backups"}, {"name": "Backups", "url": "/admin/backups"},
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
robots="noindex,nofollow",
) )
return respond( return respond(
request, request,
+1
View File
@@ -139,6 +139,7 @@ async def admin_devii_tasks(request: Request, state: str = "active"):
{"name": "Devii tasks", "url": "/admin/devii-tasks"}, {"name": "Devii tasks", "url": "/admin/devii-tasks"},
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
robots="noindex,nofollow",
) )
return respond( return respond(
request, request,
+1 -1
View File
@@ -62,7 +62,7 @@ async def service_detail(request: Request, name: str):
request, request,
title=f"{info['title']} - Services", title=f"{info['title']} - Services",
description=info["description"] or f"Configure the {info['title']} service.", description=info["description"] or f"Configure the {info['title']} service.",
robots="noindex", robots="noindex,nofollow",
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Admin", "url": "/admin"}, {"name": "Admin", "url": "/admin"},
+1
View File
@@ -89,6 +89,7 @@ async def admin_trash(request: Request, table: str = "posts", page: int = 1):
{"name": "Trash", "url": "/admin/trash"}, {"name": "Trash", "url": "/admin/trash"},
], ],
schemas=[website_schema(base)], schemas=[website_schema(base)],
robots="noindex,nofollow",
) )
return respond( return respond(
request, request,
+2 -7
View File
@@ -5,7 +5,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import db, get_table, get_users_by_uids from devplacepy.database import get_projects_by_uids, get_table, get_users_by_uids
from devplacepy.dependencies import json_or_form from devplacepy.dependencies import json_or_form
from devplacepy.models import ( from devplacepy.models import (
EditorPrefsForm, EditorPrefsForm,
@@ -34,13 +34,8 @@ router = APIRouter()
def _decorate(rows: list[dict]) -> list[dict]: def _decorate(rows: list[dict]) -> list[dict]:
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")} owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {} owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
projects = {}
if "projects" in db.tables:
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")} project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
for uid in project_uids: projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
found = get_table("projects").find_one(uid=uid)
if found:
projects[uid] = found
decorated = [] decorated = []
for row in rows: for row in rows:
view = provision.view(row) view = provision.view(row)
-6
View File
@@ -22,12 +22,6 @@ async def token(
request: Request, request: Request,
data: Annotated[LoginForm, Depends(json_or_form(LoginForm))], data: Annotated[LoginForm, Depends(json_or_form(LoginForm))],
): ):
"""Issue a DevPlace access token.
Accepts ``email`` + ``password`` (JSON or form-encoded). Returns a JSON
object with ``access_token``, ``token_type``, and ``expires_in`` on success,
or a ``401`` error on bad credentials.
"""
identifier = data.email.strip().lower() identifier = data.email.strip().lower()
password = data.password password = data.password
+3 -1
View File
@@ -11,7 +11,7 @@ from devplacepy.dependencies import json_or_form
from devplacepy.models import WarJoinForm from devplacepy.models import WarJoinForm
from devplacepy.responses import action_result, json_error, respond, wants_json from devplacepy.responses import action_result, json_error, respond, wants_json
from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut from devplacepy.schemas import BattlesOut, WarEventsOut, WarOut
from devplacepy.seo import base_seo_context from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.services.opinionwar import WarError, rules, store from devplacepy.services.opinionwar import WarError, rules, store
from devplacepy.utils import get_current_user, not_found, require_user from devplacepy.utils import get_current_user, not_found, require_user
@@ -43,6 +43,7 @@ async def battles_page(
search=search, search=search,
page=max(1, page), page=max(1, page),
) )
base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title="Opinion Wars", title="Opinion Wars",
@@ -54,6 +55,7 @@ async def battles_page(
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Battles", "url": "/battles"}, {"name": "Battles", "url": "/battles"},
], ],
schemas=[website_schema(base)],
) )
return respond( return respond(
request, request,
-2
View File
@@ -137,8 +137,6 @@ def _validate_target(value: str) -> str:
@router.get("/adopt") @router.get("/adopt")
async def devii_adopt(request: Request): async def devii_adopt(request: Request):
# The terminal's agent logged in; adopt the real session it minted into the browser so both
# share one session, then return to where the user was.
target = _validate_target(request.query_params.get("next", "/")) target = _validate_target(request.query_params.get("next", "/"))
response = RedirectResponse(target, status_code=303) response = RedirectResponse(target, status_code=303)
svc = _service() svc = _service()
+1 -1
View File
@@ -66,7 +66,7 @@ The `endpoint()` factory derives `min_role` from `auth` (`public` -> Public, `us
## Admin-only pages ## Admin-only pages
A group with `"admin": True` (currently `services`, `admin`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`. A group with `"admin": True` (currently `services`, `admin`, `containers`) is hidden from the sidebar (`docs.py` filters `visible_pages`) and returns 404 for non-admins (`page.get("admin") and not is_admin -> not_found`, same message as unknown slugs). `index.html` wraps the operator links in `{% if user and user.get('role') == 'Admin' %}`.
## Dynamic Background Services page ## Dynamic Background Services page
-15
View File
@@ -44,7 +44,6 @@ AUDIENCES = [
] ]
DOCS_PAGES = [ DOCS_PAGES = [
# General - how to use the site and Devii (everyone)
{"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL}, {"slug": "index", "title": "Overview", "kind": "prose", "section": SECTION_GENERAL},
{ {
"slug": "getting-started", "slug": "getting-started",
@@ -160,7 +159,6 @@ DOCS_PAGES = [
"kind": "prose", "kind": "prose",
"section": SECTION_GENERAL, "section": SECTION_GENERAL,
}, },
# Legal - the policies the platform is operated under (everyone)
{ {
"slug": "terms", "slug": "terms",
"title": "Terms of Service", "title": "Terms of Service",
@@ -204,7 +202,6 @@ DOCS_PAGES = [
"section": SECTION_LEGAL, "section": SECTION_LEGAL,
"admin": True, "admin": True,
}, },
# Tools - public developer tools (everyone)
{ {
"slug": "tools-seo", "slug": "tools-seo",
"title": "SEO Diagnostics", "title": "SEO Diagnostics",
@@ -229,7 +226,6 @@ DOCS_PAGES = [
"kind": "prose", "kind": "prose",
"section": SECTION_TOOLS, "section": SECTION_TOOLS,
}, },
# Claude Code - the native subagent, command, and workflow setup under .claude/
{ {
"slug": "claude", "slug": "claude",
"title": "Claude Code setup", "title": "Claude Code setup",
@@ -260,7 +256,6 @@ DOCS_PAGES = [
"kind": "prose", "kind": "prose",
"section": SECTION_CLAUDE, "section": SECTION_CLAUDE,
}, },
# Components - custom HTML web components with live examples (everyone)
{ {
"slug": "components", "slug": "components",
"title": "Components overview", "title": "Components overview",
@@ -339,7 +334,6 @@ DOCS_PAGES = [
"kind": "prose", "kind": "prose",
"section": SECTION_COMPONENTS, "section": SECTION_COMPONENTS,
}, },
# Styles - the design system: colors, layout, responsiveness, and hard structural rules (everyone)
{ {
"slug": "styles", "slug": "styles",
"title": "Design system overview", "title": "Design system overview",
@@ -370,7 +364,6 @@ DOCS_PAGES = [
"kind": "prose", "kind": "prose",
"section": SECTION_STYLES, "section": SECTION_STYLES,
}, },
# API - developer reference (everyone); admin-only groups are routed to Administration below
{ {
"slug": "authentication", "slug": "authentication",
"title": "Authentication", "title": "Authentication",
@@ -383,7 +376,6 @@ DOCS_PAGES = [
"kind": "prose", "kind": "prose",
"section": SECTION_API, "section": SECTION_API,
}, },
# devRant API - legacy-compatible protocol, spread over focused pages
{ {
"slug": "devrant", "slug": "devrant",
"title": "Overview", "title": "Overview",
@@ -430,7 +422,6 @@ DOCS_PAGES = [
{**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)} {**page, "section": (SECTION_ADMIN if page.get("admin") else SECTION_API)}
for page in api_doc_pages() for page in api_doc_pages()
], ],
# Administration - operational guides (admins only)
{ {
"slug": "devii-admin", "slug": "devii-admin",
"title": "Devii for admins", "title": "Devii for admins",
@@ -473,7 +464,6 @@ DOCS_PAGES = [
"admin": True, "admin": True,
"section": SECTION_ADMIN, "section": SECTION_ADMIN,
}, },
# Devii internals - technical reference for the Devii assistant (admins only)
{ {
"slug": "audit-log", "slug": "audit-log",
"title": "Audit Log", "title": "Audit Log",
@@ -523,7 +513,6 @@ DOCS_PAGES = [
"admin": True, "admin": True,
"section": SECTION_DEVII, "section": SECTION_DEVII,
}, },
# Bots internals - deep technical reference for the autonomous bot fleet (admins only)
{ {
"slug": "bots-internals", "slug": "bots-internals",
"title": "Bots internals", "title": "Bots internals",
@@ -573,7 +562,6 @@ DOCS_PAGES = [
"admin": True, "admin": True,
"section": SECTION_BOTS, "section": SECTION_BOTS,
}, },
# Services - the background service framework and every service in detail (admins only)
{ {
"slug": "services-overview", "slug": "services-overview",
"title": "Services overview", "title": "Services overview",
@@ -651,7 +639,6 @@ DOCS_PAGES = [
"admin": True, "admin": True,
"section": SECTION_SERVICES, "section": SECTION_SERVICES,
}, },
# Architecture - platform design, structure, and development process (admins only)
{ {
"slug": "architecture", "slug": "architecture",
"title": "Architecture overview", "title": "Architecture overview",
@@ -701,7 +688,6 @@ DOCS_PAGES = [
"admin": True, "admin": True,
"section": SECTION_ARCH, "section": SECTION_ARCH,
}, },
# Testing - test framework, load testing, and make targets (admins only)
{ {
"slug": "testing", "slug": "testing",
"title": "Testing overview", "title": "Testing overview",
@@ -737,7 +723,6 @@ DOCS_PAGES = [
"admin": True, "admin": True,
"section": SECTION_TESTING, "section": SECTION_TESTING,
}, },
# Production - deployment and operations reference (admins only)
{ {
"slug": "production", "slug": "production",
"title": "Production overview", "title": "Production overview",
+41 -1
View File
@@ -9,6 +9,7 @@ from devplacepy.models import GameSlotForm
from devplacepy.responses import respond, wants_json from devplacepy.responses import respond, wants_json
from devplacepy.schemas import GameFarmViewOut from devplacepy.schemas import GameFarmViewOut
from devplacepy.services.game import GameError, store from devplacepy.services.game import GameError, store
from devplacepy.services.audit import record as audit
from devplacepy.utils import ( from devplacepy.utils import (
create_notification, create_notification,
get_current_user, get_current_user,
@@ -58,10 +59,24 @@ async def water_farm(
if not owner: if not owner:
raise HTTPException(status_code=404, detail="Farm not found") raise HTTPException(status_code=404, detail="Farm not found")
try: try:
store.water(viewer, owner, data.slot) result = store.water(viewer, owner, data.slot)
except GameError as exc: except GameError as exc:
return action_error(request, str(exc), f"/game/farm/{username}") return action_error(request, str(exc), f"/game/farm/{username}")
track_action(viewer["uid"], "water") track_action(viewer["uid"], "water")
audit.record(
request,
"game.water",
user=viewer,
target_type="user",
target_uid=owner["uid"],
target_label=owner["username"],
metadata=result,
summary=(
f"{viewer['username']} watered {owner['username']}'s build on plot "
f"{result['slot']} and earned {result['reward_coins']} coins"
),
links=[audit.target("user", owner["uid"], owner["username"])],
)
await notify_farm(owner["username"]) await notify_farm(owner["username"])
if wants_json(request): if wants_json(request):
payload = store.serialize_farm( payload = store.serialize_farm(
@@ -87,6 +102,31 @@ async def steal_farm(
track_action(owner["uid"], "got_stolen_from") track_action(owner["uid"], "got_stolen_from")
if result.get("underdog_triggered"): if result.get("underdog_triggered"):
track_action(viewer["uid"], "underdog_raid") track_action(viewer["uid"], "underdog_raid")
audit.record(
request,
"game.steal",
user=viewer,
target_type="user",
target_uid=owner["uid"],
target_label=owner["username"],
metadata={
"thief_uid": viewer["uid"],
"thief_username": viewer["username"],
"owner_uid": owner["uid"],
"owner_username": owner["username"],
"slot": result["slot"],
"crop": result["crop"],
"coins": result["coins"],
"share": result["share"],
"underdog_triggered": result.get("underdog_triggered", False),
},
summary=(
f"{viewer['username']} raided {owner['username']}'s Code Farm and took "
f"{result['coins']} coins ({round(result['share'] * 100)}%) from their "
f"{result['crop_name']} build"
),
links=[audit.target("user", owner["uid"], owner["username"])],
)
create_notification( create_notification(
owner["uid"], owner["uid"],
"harvest_stolen", "harvest_stolen",
+145 -9
View File
@@ -80,8 +80,21 @@ async def _respond_action(request: Request, user: dict, fn, on_success=None):
@router.post("/plant") @router.post("/plant")
async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]): async def game_plant(request: Request, data: Annotated[GamePlantForm, Form()]):
user = require_user(request) user = require_user(request)
def recorded(result):
audit.record(
request,
"game.plant",
user=user,
metadata=result,
summary=(
f"{user['username']} planted {result['crop']} on plot "
f"{result['slot']} for {result['spent']} coins"
),
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.plant(user, data.slot, data.crop) request, user, lambda: store.plant(user, data.slot, data.crop), recorded
) )
@@ -92,6 +105,16 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
def reward(result): def reward(result):
track_action(user["uid"], "harvest") track_action(user["uid"], "harvest")
award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0))) award_rewards(user["uid"], economy.site_xp_for(result.get("xp", 0)))
audit.record(
request,
"game.harvest",
user=user,
metadata=result,
summary=(
f"{user['username']} harvested {result['crop']} on plot "
f"{result['slot']} for {result['coins']} coins"
),
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.harvest(user, data.slot), reward request, user, lambda: store.harvest(user, data.slot), reward
@@ -101,25 +124,79 @@ async def game_harvest(request: Request, data: Annotated[GameSlotForm, Form()]):
@router.post("/buy-plot") @router.post("/buy-plot")
async def game_buy_plot(request: Request): async def game_buy_plot(request: Request):
user = require_user(request) user = require_user(request)
return await _respond_action(request, user, lambda: store.buy_plot(user))
def recorded(result):
audit.record(
request,
"game.plot.buy",
user=user,
metadata=result,
summary=(
f"{user['username']} bought Code Farm plot "
f"{result['plot_count']} for {result['spent']} coins"
),
)
return await _respond_action(request, user, lambda: store.buy_plot(user), recorded)
@router.post("/upgrade") @router.post("/upgrade")
async def game_upgrade(request: Request): async def game_upgrade(request: Request):
user = require_user(request) user = require_user(request)
return await _respond_action(request, user, lambda: store.upgrade_ci(user))
def recorded(result):
audit.record(
request,
"game.ci.upgrade",
user=user,
metadata=result,
summary=(
f"{user['username']} upgraded Code Farm CI to tier "
f"{result['ci_tier']} for {result['spent']} coins"
),
)
return await _respond_action(request, user, lambda: store.upgrade_ci(user), recorded)
@router.post("/fertilize") @router.post("/fertilize")
async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]): async def game_fertilize(request: Request, data: Annotated[GameSlotForm, Form()]):
user = require_user(request) user = require_user(request)
return await _respond_action(request, user, lambda: store.fertilize(user, data.slot))
def recorded(result):
audit.record(
request,
"game.fertilize",
user=user,
metadata=result,
summary=(
f"{user['username']} fertilized plot {result['slot']} "
f"for {result['spent']} coins"
),
)
return await _respond_action(
request, user, lambda: store.fertilize(user, data.slot), recorded
)
@router.post("/daily") @router.post("/daily")
async def game_daily(request: Request): async def game_daily(request: Request):
user = require_user(request) user = require_user(request)
return await _respond_action(request, user, lambda: store.claim_daily(user))
def recorded(result):
audit.record(
request,
"game.daily.claim",
user=user,
metadata=result,
summary=(
f"{user['username']} claimed the Code Farm daily bonus of "
f"{result['reward']} coins (streak {result['streak']})"
),
)
return await _respond_action(request, user, lambda: store.claim_daily(user), recorded)
@router.post("/grant") @router.post("/grant")
@@ -141,8 +218,21 @@ async def game_claim_grant(request: Request):
@router.post("/perk") @router.post("/perk")
async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]): async def game_perk(request: Request, data: Annotated[GamePerkForm, Form()]):
user = require_user(request) user = require_user(request)
def recorded(result):
audit.record(
request,
"game.perk.upgrade",
user=user,
metadata=result,
summary=(
f"{user['username']} upgraded perk {result['perk']} to level "
f"{result['level']} for {result['spent']} coins"
),
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.upgrade_perk(user, data.perk) request, user, lambda: store.upgrade_perk(user, data.perk), recorded
) )
@@ -168,8 +258,21 @@ async def game_prestige(request: Request):
@router.post("/legacy") @router.post("/legacy")
async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]): async def game_legacy(request: Request, data: Annotated[GameLegacyForm, Form()]):
user = require_user(request) user = require_user(request)
def recorded(result):
audit.record(
request,
"game.legacy.upgrade",
user=user,
metadata=result,
summary=(
f"{user['username']} upgraded Legacy {result['key']} to level "
f"{result['level']} for {result['spent']} stars"
),
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.upgrade_legacy(user, data.key) request, user, lambda: store.upgrade_legacy(user, data.key), recorded
) )
@@ -179,6 +282,16 @@ async def game_claim_quest(request: Request, data: Annotated[GameQuestForm, Form
def reward(result): def reward(result):
award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0))) award_rewards(user["uid"], economy.site_xp_for(result.get("reward_xp", 0)))
audit.record(
request,
"game.quest.claim",
user=user,
metadata=result,
summary=(
f"{user['username']} claimed quest {result['kind']} for "
f"{result['reward_coins']} coins"
),
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward request, user, lambda: store.claim_quest(user, data.quest, data.scope), reward
@@ -251,8 +364,21 @@ async def game_buy_infrastructure(request: Request, data: Annotated[GameInfraFor
@router.post("/mastery") @router.post("/mastery")
async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]): async def game_upgrade_mastery(request: Request, data: Annotated[GameMasteryForm, Form()]):
user = require_user(request) user = require_user(request)
def recorded(result):
audit.record(
request,
"game.mastery.upgrade",
user=user,
metadata=result,
summary=(
f"{user['username']} upgraded Mastery {result['key']} to level "
f"{result['level']} for {result['spent']} mastery points"
),
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.upgrade_mastery(user, data.key) request, user, lambda: store.upgrade_mastery(user, data.key), recorded
) )
@@ -281,6 +407,16 @@ async def game_buy_cosmetic(request: Request, data: Annotated[GameCosmeticForm,
@router.post("/cosmetics/equip") @router.post("/cosmetics/equip")
async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]): async def game_equip_cosmetic(request: Request, data: Annotated[GameCosmeticForm, Form()]):
user = require_user(request) user = require_user(request)
def recorded(result):
audit.record(
request,
"game.cosmetic.equip",
user=user,
metadata=result,
summary=f"{user['username']} equipped Code Farm title {result['active_title']}",
)
return await _respond_action( return await _respond_action(
request, user, lambda: store.equip_title(user, data.key) request, user, lambda: store.equip_title(user, data.key), recorded
) )
+8 -21
View File
@@ -8,10 +8,12 @@ from fastapi.responses import JSONResponse
from devplacepy.attachments import ( from devplacepy.attachments import (
get_attachments, get_attachments,
get_orphan_attachments_batch,
link_attachments, link_attachments,
mirror_attachment_to_gitea, mirror_attachment_to_gitea,
remove_gitea_asset, remove_gitea_asset,
soft_delete_attachment, soft_delete_attachment,
split_attachment_uids,
) )
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.dependencies import json_or_form from devplacepy.dependencies import json_or_form
@@ -28,10 +30,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _split_uids(raw) -> list[str]:
return [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool: def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool:
if not user or not is_open: if not user or not is_open:
return False return False
@@ -47,21 +45,6 @@ def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
return items return items
def _claim_orphans(uids: list[str], user: dict) -> list[str]:
admin = is_admin(user)
owned = []
for uid in uids:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
continue
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
continue
if row.get("target_uid"):
continue
owned.append(uid)
return owned
async def _load_issue(number: int) -> dict: async def _load_issue(number: int) -> dict:
try: try:
return await runtime.get_client().get_issue(number) return await runtime.get_client().get_issue(number)
@@ -106,7 +89,9 @@ async def add_issue_attachment(
summary=f"{user['username']} tried to attach to closed issue #{number}", summary=f"{user['username']} tried to attach to closed issue #{number}",
) )
return json_error(409, "Attachments cannot be changed on a closed issue") return json_error(409, "Attachments cannot be changed on a closed issue")
owned = _claim_orphans(_split_uids(data.attachment_uids), user) owned = get_orphan_attachments_batch(
split_attachment_uids(data.attachment_uids), user, admin=is_admin(user)
)
if not owned: if not owned:
return json_error(400, "No valid attachments to add") return json_error(400, "No valid attachments to add")
link_attachments(owned, "issue", str(number)) link_attachments(owned, "issue", str(number))
@@ -202,7 +187,9 @@ async def add_comment_attachment(
issue = await _load_issue(number) issue = await _load_issue(number)
if issue.get("state") != STATE_OPEN: if issue.get("state") != STATE_OPEN:
return json_error(409, "Attachments cannot be changed on a closed issue") return json_error(409, "Attachments cannot be changed on a closed issue")
owned = _claim_orphans(_split_uids(data.attachment_uids), user) owned = get_orphan_attachments_batch(
split_attachment_uids(data.attachment_uids), user, admin=is_admin(user)
)
if not owned: if not owned:
return json_error(400, "No valid attachments to add") return json_error(400, "No valid attachments to add")
link_attachments(owned, "issue_comment", str(cid)) link_attachments(owned, "issue_comment", str(cid))
+9 -14
View File
@@ -5,7 +5,12 @@ from typing import Annotated
from fastapi import Depends, APIRouter, Request from fastapi import Depends, APIRouter, Request
from devplacepy.attachments import link_attachments, mirror_attachment_to_gitea from devplacepy.attachments import (
get_orphan_attachments_batch,
link_attachments,
mirror_attachment_to_gitea,
split_attachment_uids,
)
from devplacepy.database import get_table from devplacepy.database import get_table
from devplacepy.models import IssueCommentForm from devplacepy.models import IssueCommentForm
from devplacepy.responses import action_result, json_error from devplacepy.responses import action_result, json_error
@@ -21,18 +26,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
owned = []
for uid in flat:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row or row.get("target_uid"):
continue
if row.get("user_uid") and row["user_uid"] != user["uid"]:
continue
owned.append(uid)
return owned
def _notify_admins(actor: dict, number: int) -> None: def _notify_admins(actor: dict, number: int) -> None:
for admin in get_table("users").find(role="Admin"): for admin in get_table("users").find(role="Admin"):
if admin["uid"] == actor["uid"]: if admin["uid"] == actor["uid"]:
@@ -74,7 +67,9 @@ async def comment_issue(
comment_id = int(comment.get("id", 0)) comment_id = int(comment.get("id", 0))
store.record_comment_author(comment_id, number, user["uid"]) store.record_comment_author(comment_id, number, user["uid"])
owned = _owned_orphan_uids(data.attachment_uids, user) owned = get_orphan_attachments_batch(
split_attachment_uids(data.attachment_uids), user
)
if owned: if owned:
link_attachments(owned, "issue_comment", str(comment_id)) link_attachments(owned, "issue_comment", str(comment_id))
for uid in owned: for uid in owned:
+4 -15
View File
@@ -6,7 +6,7 @@ from typing import Annotated
from fastapi import Depends, APIRouter, Request from fastapi import Depends, APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from devplacepy.database import get_table from devplacepy.attachments import get_orphan_attachments_batch, split_attachment_uids
from devplacepy.models import IssueForm from devplacepy.models import IssueForm
from devplacepy.responses import json_error from devplacepy.responses import json_error
from devplacepy.schemas import IssueJobOut from devplacepy.schemas import IssueJobOut
@@ -20,19 +20,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
owned = []
for uid in flat:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row or row.get("target_uid"):
continue
if row.get("user_uid") and row["user_uid"] != user["uid"]:
continue
owned.append(uid)
return owned
@router.post("/create") @router.post("/create")
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]): async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
user = require_user(request) user = require_user(request)
@@ -45,7 +32,9 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json
"author_uid": user["uid"], "author_uid": user["uid"],
"title": title, "title": title,
"description": data.description.strip(), "description": data.description.strip(),
"attachment_uids": _owned_orphan_uids(data.attachment_uids, user), "attachment_uids": get_orphan_attachments_batch(
split_attachment_uids(data.attachment_uids), user
),
}, },
"user", "user",
user["uid"], user["uid"],
@@ -83,8 +83,6 @@ async def containers_json(request: Request, project_slug: str):
} }
) )
# ---------------- instances ----------------
@router.post("/{project_slug}/containers/instances") @router.post("/{project_slug}/containers/instances")
async def create_instance( async def create_instance(
request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Depends(json_or_form(ContainerInstanceForm))] request: Request, project_slug: str, data: Annotated[ContainerInstanceForm, Depends(json_or_form(ContainerInstanceForm))]
+1
View File
@@ -477,6 +477,7 @@ def _build_sitemap(base_url):
urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5")) urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5"))
urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5")) urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5"))
urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5")) urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5"))
urlset.append(url_element(f"{base_url}/tools/isslop", changefreq="monthly", priority="0.5"))
urlset.append( urlset.append(
url_element(f"{base_url}/workspaces/index", changefreq="daily", priority="0.6") url_element(f"{base_url}/workspaces/index", changefreq="daily", priority="0.6")
) )
-13
View File
@@ -16,11 +16,6 @@ def issue_token(
label: str = "", label: str = "",
max_age_days: Optional[int] = None, max_age_days: Optional[int] = None,
) -> dict: ) -> dict:
"""Issue a DevPlace access token for *user*.
Returns a dict with *access_token*, *token_type*, *expires_in* (seconds),
*expires_at* (ISO), and *uid*.
"""
if max_age_days is None: if max_age_days is None:
max_age_days = max(1, get_int_setting("session_max_age_days", 7)) max_age_days = max(1, get_int_setting("session_max_age_days", 7))
max_age_seconds = max_age_days * SECONDS_PER_DAY max_age_seconds = max_age_days * SECONDS_PER_DAY
@@ -54,11 +49,6 @@ def issue_token(
def resolve_token(token: str) -> Optional[dict]: def resolve_token(token: str) -> Optional[dict]:
"""Resolve a user from an access token string.
Returns the user dict or ``None`` when the token is invalid, expired, or
belongs to an inactive user.
"""
if not token: if not token:
return None return None
row = get_table("access_tokens").find_one(token=token, deleted_at=None) row = get_table("access_tokens").find_one(token=token, deleted_at=None)
@@ -83,7 +73,6 @@ def resolve_token(token: str) -> Optional[dict]:
def revoke_token(uid: str) -> bool: def revoke_token(uid: str) -> bool:
"""Soft-delete a single access token by its uid. Returns ``True`` on success."""
tokens = get_table("access_tokens") tokens = get_table("access_tokens")
row = tokens.find_one(uid=uid, deleted_at=None) row = tokens.find_one(uid=uid, deleted_at=None)
if not row: if not row:
@@ -97,7 +86,6 @@ def revoke_token(uid: str) -> bool:
def revoke_all(user_uid: str) -> int: def revoke_all(user_uid: str) -> int:
"""Soft-delete every access token for *user_uid*. Returns the count revoked."""
tokens = get_table("access_tokens") tokens = get_table("access_tokens")
stamp = datetime.now(timezone.utc).isoformat() stamp = datetime.now(timezone.utc).isoformat()
count = 0 count = 0
@@ -112,7 +100,6 @@ def revoke_all(user_uid: str) -> int:
def prune_expired() -> int: def prune_expired() -> int:
"""Soft-delete all expired access tokens. Returns the count pruned."""
tokens = get_table("access_tokens") tokens = get_table("access_tokens")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
stamp = now.isoformat() stamp = now.isoformat()
-6
View File
@@ -179,9 +179,6 @@ def parse_env(value) -> dict:
return env return env
# ---------------- instances ----------------
def validate_ingress(slug: str, port, port_list) -> tuple: def validate_ingress(slug: str, port, port_list) -> tuple:
slug = (slug or "").strip().lower() slug = (slug or "").strip().lower()
if not slug: if not slug:
@@ -656,9 +653,6 @@ def add_schedule(instance: dict, action: str, schedule: Schedule) -> dict:
return store.create_schedule(instance, action, schedule.columns(), to_iso(first)) return store.create_schedule(instance, action, schedule.columns(), to_iso(first))
# ---------------- aggregation ----------------
def _percentile(values: list, pct: float) -> float: def _percentile(values: list, pct: float) -> float:
if not values: if not values:
return 0.0 return 0.0
-6
View File
@@ -32,9 +32,6 @@ def _exists(table: str) -> bool:
return table in db.tables return table in db.tables
# ---------------- instances ----------------
def create_instance(row: dict) -> dict: def create_instance(row: dict) -> dict:
uid = generate_uid() uid = generate_uid()
base = { base = {
@@ -138,9 +135,6 @@ def delete_instance(uid: str, deleted_by: str = "system") -> None:
) )
# ---------------- events / metrics / schedules ----------------
def record_event( def record_event(
instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None instance: dict, event: str, actor_kind: str, actor_id: str, detail: dict = None
) -> None: ) -> None:
-1
View File
@@ -49,7 +49,6 @@ class VectorStore:
self._dims: int | None = None self._dims: int | None = None
async def _run_sync(self, func, *args, timeout: float = CHROMADB_TIMEOUT): async def _run_sync(self, func, *args, timeout: float = CHROMADB_TIMEOUT):
"""Run a synchronous ChromaDB call in a thread executor with a timeout."""
try: try:
return await asyncio.wait_for( return await asyncio.wait_for(
asyncio.to_thread(func, *args), timeout=timeout asyncio.to_thread(func, *args), timeout=timeout
@@ -231,6 +231,30 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
params=(path("uid", "News uid."), confirm()), params=(path("uid", "News uid."), confirm()),
requires_admin=True, requires_admin=True,
), ),
Action(
name="admin_get_notification_defaults",
method="GET",
path="/admin/notifications",
summary="View site-wide default notification settings for every type/channel",
requires_admin=True,
),
Action(
name="admin_set_notification_default",
method="POST",
path="/admin/notifications",
summary="Set the site-wide default for a notification type and channel",
params=(
body("notification_type", "Notification type key, e.g. 'follow', 'mention'.", required=True),
body("channel", "Delivery channel: in_app, push, or telegram.", required=True),
body(
"value",
"true to enable this default, false to disable it.",
required=True,
type="boolean",
),
),
requires_admin=True,
),
Action( Action(
name="admin_list_services", name="admin_list_services",
method="GET", method="GET",
@@ -432,6 +456,47 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
), ),
requires_admin=True, requires_admin=True,
), ),
Action(
name="backup_schedule_edit",
method="POST",
path="/admin/backups/schedules/{uid}/edit",
summary="Edit a recurring backup schedule (admin only)",
description=(
"Replaces every field of an existing schedule (name, target, kind, every_seconds/cron, "
"keep_last) - this is a full update, not a partial patch."
),
params=(
path("uid", "Backup schedule uid."),
body("name", "Human-readable schedule name.", required=True),
body("target", "One of database, uploads, keys, full.", required=True),
body("kind", "interval or cron.", required=True),
body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
),
requires_admin=True,
),
Action(
name="backup_schedule_toggle",
method="POST",
path="/admin/backups/schedules/{uid}/toggle",
summary="Enable or disable a backup schedule (admin only)",
description="Flips the schedule's enabled state; a disabled schedule never fires.",
params=(path("uid", "Backup schedule uid."),),
requires_admin=True,
),
Action(
name="backup_schedule_run",
method="POST",
path="/admin/backups/schedules/{uid}/run",
summary="Run a backup schedule immediately (admin only)",
description=(
"Enqueues an out-of-band backup job using the schedule's target and keep_last, without "
"waiting for its next scheduled fire. Poll backup_status with the resulting job uid."
),
params=(path("uid", "Backup schedule uid."),),
requires_admin=True,
),
Action( Action(
name="backup_schedule_delete", name="backup_schedule_delete",
method="POST", method="POST",
@@ -444,6 +509,35 @@ ADMIN_ACTIONS: tuple[Action, ...] = (
params=(path("uid", "Backup schedule uid."), confirm()), params=(path("uid", "Backup schedule uid."), confirm()),
requires_admin=True, requires_admin=True,
), ),
Action(
name="admin_list_devii_tasks",
method="GET",
path="/admin/devii-tasks",
summary="List every scheduled Devii task across all owners (admin only)",
description=(
"Returns every scheduled Devii task regardless of owner, with its schedule, status, "
"run/failure counts, quotas, and bounds. state is one of active, inactive, all."
),
params=(query("state", "Filter: active, inactive, or all."),),
requires_admin=True,
),
Action(
name="admin_devii_task_disable",
method="POST",
path="/admin/devii-tasks/{uid}/disable",
summary="Disable another owner's scheduled Devii task (admin only)",
description="Stops the task from firing again; it is not deleted and can be re-enabled by its owner.",
params=(path("uid", "Devii task uid."),),
requires_admin=True,
),
Action(
name="admin_devii_task_delete",
method="POST",
path="/admin/devii-tasks/{uid}/delete",
summary="Delete another owner's scheduled Devii task (admin only). Soft delete, confirmation required",
params=(path("uid", "Devii task uid."), confirm()),
requires_admin=True,
),
Action( Action(
name="restore_media", name="restore_media",
method="POST", method="POST",
@@ -44,8 +44,9 @@ ISSUE_ACTIONS: tuple[Action, ...] = (
"comma-separated list of issue numbers to plan only those tickets; omit it to plan " "comma-separated list of issue numbers to plan only those tickets; omit it to plan "
"every open ticket. Each ticket's full description is reproduced verbatim, both " "every open ticket. Each ticket's full description is reproduced verbatim, both "
"inline and in a 'Source Tickets (verbatim)' appendix, so the document is " "inline and in a 'Source Tickets (verbatim)' appendix, so the document is "
"self-contained. Poll the status_url until status is 'done', then show the markdown " "self-contained. Poll with planning_status until status is 'done', then show the "
"and the download_url. Admin only." "user the markdown and the download_url (fetch the file itself with "
"planning_download if asked). Admin only."
), ),
params=( params=(
body( body(
@@ -56,6 +57,33 @@ ISSUE_ACTIONS: tuple[Action, ...] = (
requires_auth=True, requires_auth=True,
requires_admin=True, requires_admin=True,
), ),
Action(
name="planning_status",
method="GET",
path="/issues/planning/{uid}",
summary="Check a planning report job and read it once finished",
description=(
"Returns the job status. When status is 'done', the full markdown document and "
"download_url are populated; while 'pending' or 'running', poll again shortly. Admin only."
),
params=(path("uid", "Planning job uid returned by planning_report_generate."),),
requires_auth=True,
requires_admin=True,
),
Action(
name="planning_download",
method="GET",
path="/issues/planning/{uid}/download",
summary="Download the finished planning report file",
description=(
"Returns the raw markdown file of a finished planning report. Use planning_status "
"first; this is only needed if the user wants the file content fetched directly "
"rather than shown inline. Admin only."
),
params=(path("uid", "Planning job uid returned by planning_report_generate."),),
requires_auth=True,
requires_admin=True,
),
Action( Action(
name="issue_job_status", name="issue_job_status",
method="GET", method="GET",
@@ -108,6 +108,30 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
params=(path("uid", "DeepSearch job uid returned by deepsearch."),), params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
requires_auth=False, requires_auth=False,
), ),
Action(
name="deepsearch_pause",
method="POST",
path="/tools/deepsearch/{uid}/pause",
summary="Pause a running DeepSearch job",
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
requires_auth=False,
),
Action(
name="deepsearch_resume",
method="POST",
path="/tools/deepsearch/{uid}/resume",
summary="Resume a paused DeepSearch job",
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
requires_auth=False,
),
Action(
name="deepsearch_cancel",
method="POST",
path="/tools/deepsearch/{uid}/cancel",
summary="Cancel a running or paused DeepSearch job",
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
requires_auth=False,
),
Action( Action(
name="isslop", name="isslop",
method="POST", method="POST",
@@ -122,7 +146,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
params=( params=(
body("url", "Repository or website URL to classify.", required=True), body("url", "Repository or website URL to classify.", required=True),
), ),
requires_auth=True, requires_auth=False,
), ),
Action( Action(
name="isslop_status", name="isslop_status",
@@ -135,7 +159,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
"'running', poll again shortly." "'running', poll again shortly."
), ),
params=(path("uid", "Analysis uid returned by isslop."),), params=(path("uid", "Analysis uid returned by isslop."),),
requires_auth=True, requires_auth=False,
), ),
Action( Action(
name="isslop_report", name="isslop_report",
@@ -148,7 +172,7 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
"embeddable badge snippets. Use it after isslop_status reports status 'completed'." "embeddable badge snippets. Use it after isslop_status reports status 'completed'."
), ),
params=(path("uid", "Analysis uid returned by isslop."),), params=(path("uid", "Analysis uid returned by isslop."),),
requires_auth=True, requires_auth=False,
), ),
Action( Action(
name="isslop_list", name="isslop_list",
@@ -156,10 +180,10 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
path="/tools/isslop/list", path="/tools/isslop/list",
summary="List the user's AI usage analyses", summary="List the user's AI usage analyses",
description=( description=(
"Returns the signed-in user's analysis history, newest first, each with its grade, " "Returns the signed-in user's (or guest's) analysis history, newest first, each "
"category, status and report_url." "with its grade, category, status and report_url."
), ),
params=(), params=(),
requires_auth=True, requires_auth=False,
), ),
) )
@@ -84,6 +84,7 @@ CONFIRM_REQUIRED = {
"publish_quiz", "publish_quiz",
"delete_quiz", "delete_quiz",
"delete_quiz_question", "delete_quiz_question",
"admin_devii_task_delete",
} }
CONDITIONAL_CONFIRM = { CONDITIONAL_CONFIRM = {
+16 -2
View File
@@ -14,7 +14,12 @@ from urllib.parse import urlparse
import httpx import httpx
from devplacepy import stealth from devplacepy import stealth
from devplacepy.net_guard import effective_address, is_blocked_address from devplacepy.net_guard import (
BlockedAddressError,
effective_address,
guarded_async_client,
is_blocked_address,
)
from ..config import Settings from ..config import Settings
from ..errors import NetworkError, ToolInputError, UpstreamError from ..errors import NetworkError, ToolInputError, UpstreamError
from ..text import html_to_text from ..text import html_to_text
@@ -238,8 +243,13 @@ class FetchController:
response_headers = {key: value for key, value in response.headers.items()} response_headers = {key: value for key, value in response.headers.items()}
return body, str(response.url), content_type, response.status_code, response_headers return body, str(response.url), content_type, response.status_code, response_headers
client_factory = (
stealth.stealth_async_client
if self._settings.fetch_allow_private
else guarded_async_client
)
try: try:
async with stealth.stealth_async_client( async with client_factory(
headers=merged_headers, headers=merged_headers,
follow_redirects=True, follow_redirects=True,
timeout=self._settings.fetch_timeout_seconds, timeout=self._settings.fetch_timeout_seconds,
@@ -256,5 +266,9 @@ class FetchController:
return result return result
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:
raise NetworkError(f"Request timed out fetching {url}", url=url) from exc raise NetworkError(f"Request timed out fetching {url}", url=url) from exc
except BlockedAddressError as exc:
raise ToolInputError(
f"Refusing to fetch a private or local address: {exc}"
) from exc
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise NetworkError(f"Could not fetch {url}: {exc}", url=url) from exc raise NetworkError(f"Could not fetch {url}: {exc}", url=url) from exc
-5
View File
@@ -66,11 +66,6 @@ class DeviiHub:
_user_key_resolver(owner_id) if owner_kind == "user" else None _user_key_resolver(owner_id) if owner_kind == "user" else None
) )
llm = LLMClient(settings, key_resolver=key_resolver) llm = LLMClient(settings, key_resolver=key_resolver)
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
# The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
# (and a Docii reflection never pollutes the user's Devii memory). Only the conversation
# thread persists, keyed per channel in the shared ConversationStore.
owned_db = ( owned_db = (
db db
if (owner_kind == "user" and channel in ("main", "telegram")) if (owner_kind == "user" and channel in ("main", "telegram"))
@@ -790,9 +790,6 @@ class DeviiSession:
asyncio.create_task(self._emit(payload, buffer=False)) asyncio.create_task(self._emit(payload, buffer=False))
def _sync_auth(self, name: str) -> None: def _sync_auth(self, name: str) -> None:
# When the agent changes who is logged in, propagate it to the browser so the terminal and
# the browser share one session. Login mints a real platform session in the client's cookie
# jar; hand that token to the browser via /devii/adopt. Logout reuses /auth/logout.
if name in ("login", "signup"): if name in ("login", "signup"):
token = self.client.session_cookie() token = self.client.session_cookie()
if token: if token:
@@ -1011,8 +1008,6 @@ class DeviiSession:
) )
finally: finally:
drop.cancel() drop.cancel()
# future done -> loop exits and returns; disconnected -> re-send after
# reconnect; otherwise the deadline check at the top raises.
return future.result() return future.result()
finally: finally:
self._pending.pop(request_id, None) self._pending.pop(request_id, None)
+1 -5
View File
@@ -59,11 +59,7 @@ def resolve_user(params: dict) -> Optional[dict]:
def resolve_user_by_key(key: str) -> Optional[dict]: def resolve_user_by_key(key: str) -> Optional[dict]:
"""Resolve a user from a DevRant token key alone (40-char hex). # Used by the main DevPlace auth chain so devRant tokens also work as Bearer / X-API-KEY credentials on every DevPlace endpoint.
Used by the main DevPlace auth chain so that DevRant tokens work as
Bearer / X-API-KEY credentials on every DevPlace endpoint.
"""
if not key: if not key:
return None return None
token = get_table("devrant_tokens").find_one(key=key, deleted_at=None) token = get_table("devrant_tokens").find_one(key=key, deleted_at=None)
@@ -11,8 +11,7 @@ from urllib.parse import urldefrag, urljoin, urlparse
import httpx import httpx
from devplacepy.net_guard import BlockedAddressError, guard_public_url from devplacepy.net_guard import BlockedAddressError, guard_public_url, guarded_async_client
from devplacepy.stealth import stealth_async_client
from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot from devplacepy.services.jobs.isslop.acquisition.domcapture import DomSnapshot
from devplacepy.services.jobs.isslop.acquisition.workspace import safe_relative_path from devplacepy.services.jobs.isslop.acquisition.workspace import safe_relative_path
from devplacepy.services.jobs.isslop.config import ( from devplacepy.services.jobs.isslop.config import (
@@ -136,7 +135,7 @@ async def crawl_website(
except RuntimeError as error: except RuntimeError as error:
logger.warning("Stealth browser crawl failed, falling back to HTTP client: %s", error) logger.warning("Stealth browser crawl failed, falling back to HTTP client: %s", error)
yield f"Stealth browser unavailable ({error}); falling back to HTTP client" yield f"Stealth browser unavailable ({error}); falling back to HTTP client"
async for progress in _crawl_with_http(url, workspace): async for progress in _crawl_with_http(url, workspace, allow_private=allow_private):
yield progress yield progress
@@ -247,13 +246,15 @@ async def _download_assets(
yield f"Downloaded {saved} assets concurrently (scripts, styles, sources)" yield f"Downloaded {saved} assets concurrently (scripts, styles, sources)"
async def _crawl_with_http(url: str, workspace: Path) -> AsyncIterator[str]: async def _crawl_with_http(
url: str, workspace: Path, *, allow_private: bool = False
) -> AsyncIterator[str]:
root = urlparse(url) root = urlparse(url)
state = CrawlState() state = CrawlState()
queue: list[tuple[str, int]] = [(url, 0)] queue: list[tuple[str, int]] = [(url, 0)]
headers = {"user-agent": USER_AGENT, "accept": "*/*"} headers = {"user-agent": USER_AGENT, "accept": "*/*"}
yield f"Crawling website {url} with HTTP client (max depth {WEBSITE_MAX_DEPTH}, max {WEBSITE_MAX_FILES} files)" yield f"Crawling website {url} with HTTP client (max depth {WEBSITE_MAX_DEPTH}, max {WEBSITE_MAX_FILES} files)"
async with stealth_async_client( async with guarded_async_client(
timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS, timeout=WEBSITE_REQUEST_TIMEOUT_SECONDS,
follow_redirects=True, follow_redirects=True,
headers=headers, headers=headers,
@@ -271,8 +272,17 @@ async def _crawl_with_http(url: str, workspace: Path) -> AsyncIterator[str]:
continue continue
if any(current.lower().startswith(scheme) for scheme in SKIP_SCHEMES): if any(current.lower().startswith(scheme) for scheme in SKIP_SCHEMES):
continue continue
try:
await guard_public_url(current, allow_private=allow_private)
except BlockedAddressError:
yield f"Blocked private/local address: {current}"
continue
try: try:
response = await client.get(current) response = await client.get(current)
except BlockedAddressError as error:
logger.warning("Fetch blocked %s: %s", current, error)
yield f"Blocked private/local address on redirect: {current} ({error})"
continue
except httpx.HTTPError as error: except httpx.HTTPError as error:
logger.warning("Fetch failed %s: %s", current, error) logger.warning("Fetch failed %s: %s", current, error)
yield f"Fetch failed: {current} ({error})" yield f"Fetch failed: {current} ({error})"
+1 -7
View File
@@ -38,13 +38,7 @@ def _instantiate_all():
def admin_shell_manager() -> ServiceManager: def admin_shell_manager() -> ServiceManager:
"""Read-only ServiceManager holding one instance of every managed service. # Never supervised (no set_lock_owner/supervise call): only used for describe()/config metadata/key derivation, since the owning Tier 3 process is the only one that ever ticks these services.
Never supervised (no set_lock_owner/supervise call) - used only for
describe()/config metadata/key derivation, all of which read or write
through the database layer to the shared service_state / site_settings tables.
The owning Tier 3 process is the only one that ever ticks these services.
"""
global _SHELL_MANAGER global _SHELL_MANAGER
if _SHELL_MANAGER is None: if _SHELL_MANAGER is None:
manager = ServiceManager() manager = ServiceManager()
+1 -4
View File
@@ -12,10 +12,7 @@ _LOCK_FD = None
def acquire_web_lock(lock_path: Path) -> bool: def acquire_web_lock(lock_path: Path) -> bool:
"""Non-blocking exclusive lock so exactly one `web` worker owns # Non-blocking: web is the one Tier 3 service allowed N>1 real OS workers, so exactly one must own in-process-hub-dependent work (DbApiJobService, its query WS).
in-process-hub-dependent work (DbApiJobService, its query WS).
Web is the one service that can still run N>1 real OS workers
(workers="auto"), unlike every other Tier 3 service (workers=1)."""
global _LOCK_FD global _LOCK_FD
lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = open(lock_path, "w") fd = open(lock_path, "w")
+1 -1
View File
@@ -69,7 +69,7 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
padding: 0.4rem 0.85rem; padding: 0.4rem 0.85rem;
border-radius: var(--radius-sm, 6px); border-radius: var(--radius);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8125rem; font-size: 0.8125rem;
+4 -4
View File
@@ -51,7 +51,7 @@
font-weight: 600; font-weight: 600;
background: var(--bg-card-hover); background: var(--bg-card-hover);
color: var(--text-secondary); color: var(--text-secondary);
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
} }
.audit-badge-origin { .audit-badge-origin {
@@ -88,11 +88,11 @@
.audit-result-denied { .audit-result-denied {
background: rgba(234, 179, 8, 0.15); background: rgba(234, 179, 8, 0.15);
color: var(--warning, #eab308); color: var(--warning);
} }
.audit-actor-ip { .audit-actor-ip {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
color: var(--text-muted); color: var(--text-muted);
font-size: 0.75rem; font-size: 0.75rem;
} }
@@ -148,7 +148,7 @@
} }
.audit-value-change { .audit-value-change {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
font-size: 0.8125rem; font-size: 0.8125rem;
} }
+2 -2
View File
@@ -1178,7 +1178,7 @@ body:has(.page-messages) {
transform: translateY(-150%); transform: translateY(-150%);
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
background: var(--accent); background: var(--accent);
color: #fff; color: var(--white);
border-radius: 0 0 var(--radius-input) 0; border-radius: 0 0 var(--radius-input) 0;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
@@ -1187,7 +1187,7 @@ body:has(.page-messages) {
.skip-link:focus { .skip-link:focus {
transform: translateY(0); transform: translateY(0);
outline: 2px solid #fff; outline: 2px solid var(--white);
outline-offset: 2px; outline-offset: 2px;
} }
+4 -4
View File
@@ -162,7 +162,7 @@
.cm-form textarea { .cm-form textarea {
width: 100%; width: 100%;
padding: 0.5rem 0.65rem; padding: 0.5rem 0.65rem;
background: var(--bg-input, var(--bg-card)); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
@@ -170,7 +170,7 @@
} }
.cm-form textarea { .cm-form textarea {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
resize: vertical; resize: vertical;
} }
@@ -213,7 +213,7 @@
} }
.cm-suggest li.cm-suggest-active { .cm-suggest li.cm-suggest-active {
outline: 2px solid var(--accent, var(--border)); outline: 2px solid var(--accent);
outline-offset: -2px; outline-offset: -2px;
} }
@@ -255,5 +255,5 @@
.ci-event-time { .ci-event-time {
margin-left: auto; margin-left: auto;
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
} }
+6 -6
View File
@@ -60,7 +60,7 @@
} }
.ds-degraded { .ds-degraded {
border-left: 4px solid var(--warning, #d97706); border-left: 4px solid var(--warning);
color: var(--text-primary); color: var(--text-primary);
} }
@@ -105,7 +105,7 @@
} }
.ds-form-error { .ds-form-error {
color: var(--danger, #e5484d); color: var(--danger);
margin: var(--space-md) 0 0; margin: var(--space-md) 0 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
@@ -457,7 +457,7 @@
} }
.ds-cite { .ds-cite {
color: var(--accent, #2563eb); color: var(--accent);
font-weight: 600; font-weight: 600;
font-size: 0.85em; font-size: 0.85em;
text-decoration: none; text-decoration: none;
@@ -482,9 +482,9 @@
} }
.ds-sources li:target { .ds-sources li:target {
background: var(--bg-highlight, rgba(37, 99, 235, 0.12)); background: var(--accent-light);
border-radius: var(--radius-input); border-radius: var(--radius-input);
outline: 2px solid var(--accent, #2563eb); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -580,7 +580,7 @@ dp-deepsearch-chat {
.ds-chat-msg.error .ds-chat-bubble { .ds-chat-msg.error .ds-chat-bubble {
background: var(--bg-card-hover); background: var(--bg-card-hover);
color: var(--danger, #e5484d); color: var(--danger);
} }
.ds-chat-citations { .ds-chat-citations {
+2 -2
View File
@@ -104,7 +104,7 @@ dp-docs-chat {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.05rem 0.3rem; padding: 0.05rem 0.3rem;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
font-size: 0.85em; font-size: 0.85em;
} }
@@ -127,7 +127,7 @@ dp-docs-chat {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
} }
.docs-chat-bubble .md-copy { .docs-chat-bubble .md-copy {
+4 -4
View File
@@ -2,10 +2,10 @@
.docs-layout { .docs-layout {
display: grid; display: grid;
grid-template-columns: var(--sidebar-width, 240px) 1fr; grid-template-columns: var(--sidebar-width) 1fr;
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
max-width: var(--max-content, 1200px); max-width: var(--max-content);
margin: 0 auto; margin: 0 auto;
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
} }
@@ -250,7 +250,7 @@
} }
.param-allowed-values { .param-allowed-values {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
} }
.param-input { .param-input {
@@ -563,7 +563,7 @@ pre.code-pre > .code-gutter {
padding: 0.45rem 0.6rem; padding: 0.45rem 0.6rem;
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-input, var(--bg-card)); background: var(--bg-input);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
font-family: inherit; font-family: inherit;
+2 -1
View File
@@ -7,6 +7,7 @@
--fw-accent: #3fb950; --fw-accent: #3fb950;
--fw-bar-bg: #0b0b0b; --fw-bar-bg: #0b0b0b;
--fw-border: #1c1f24; --fw-border: #1c1f24;
--fw-hover-bg: #1b1f25;
--fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6); --fw-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
--fw-radius: 10px; --fw-radius: 10px;
--fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace; --fw-font: "SFMono-Regular", "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
@@ -162,7 +163,7 @@
.fw-host .fw-winctl button:hover { .fw-host .fw-winctl button:hover {
color: var(--fw-fg); color: var(--fw-fg);
background: #1b1f25; background: var(--fw-hover-bg);
} }
.fw-host .fw-winctl button[data-win="close"]:hover { .fw-host .fw-winctl button[data-win="close"]:hover {
+8 -3
View File
@@ -1,5 +1,10 @@
/* retoor <retoor@molodetz.nl> */ /* retoor <retoor@molodetz.nl> */
:root {
--game-gold-rgb: 245, 197, 24;
--game-gold: rgb(var(--game-gold-rgb));
}
.game-page { .game-page {
max-width: var(--max-content); max-width: var(--max-content);
margin: 0 auto; margin: 0 auto;
@@ -536,12 +541,12 @@
} }
.legacy-card { .legacy-card {
border-color: #f5c518; border-color: var(--game-gold);
} }
.game-plot-golden { .game-plot-golden {
border-color: #f5c518; border-color: var(--game-gold);
box-shadow: 0 0 0 1px #f5c518 inset, 0 0 12px rgba(245, 197, 24, 0.4); box-shadow: 0 0 0 1px var(--game-gold) inset, 0 0 12px rgba(var(--game-gold-rgb), 0.4);
} }
.plot-steal-locked { .plot-steal-locked {
+3 -3
View File
@@ -43,7 +43,7 @@
.gw-default code, .gw-default code,
.admin-table .gw-code { .admin-table .gw-code {
font-family: var(--font-mono, monospace); font-family: var(--font-mono);
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-primary); color: var(--text-primary);
word-break: break-all; word-break: break-all;
@@ -125,10 +125,10 @@
.gw-field select { .gw-field select {
width: 100%; width: 100%;
padding: 0.5rem 0.65rem; padding: 0.5rem 0.65rem;
background: var(--bg-input, var(--bg-card)); background: var(--bg-input);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-input, var(--radius)); border-radius: var(--radius-input);
font: inherit; font: inherit;
} }
+3 -3
View File
@@ -226,15 +226,15 @@
height: 22px; height: 22px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
background: rgba(0, 0, 0, 0.6); background: var(--overlay-dark);
color: #fff; color: var(--white);
font-size: 1rem; font-size: 1rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
z-index: 1; z-index: 1;
} }
.attachment-gallery-item .issue-att-delete:hover { .attachment-gallery-item .issue-att-delete:hover {
background: var(--danger, #c0392b); background: var(--danger);
} }
.issue-attach-form { .issue-attach-form {
display: flex; display: flex;
+1 -1
View File
@@ -191,7 +191,7 @@
@keyframes comment-highlight-fade { @keyframes comment-highlight-fade {
0% { 0% {
background-color: var(--accent-soft, rgba(99, 102, 241, 0.18)); background-color: var(--accent-light);
} }
100% { 100% {
background-color: transparent; background-color: transparent;
+2 -2
View File
@@ -468,7 +468,7 @@ a.profile-stat-value:hover {
} }
.ai-correction-status[data-state="error"] { .ai-correction-status[data-state="error"] {
color: var(--danger, #e03131); color: var(--danger);
} }
.follow-row { .follow-row {
@@ -603,7 +603,7 @@ a.profile-stat-value:hover {
} }
.ai-quota-fill.ai-quota-danger { .ai-quota-fill.ai-quota-danger {
background: var(--danger, #d9534f); background: var(--danger);
} }
.ai-quota-meta { .ai-quota-meta {
+1 -1
View File
@@ -312,7 +312,7 @@
max-width: 80ch; max-width: 80ch;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--surface-2, var(--surface)); background: var(--bg-card);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
} }
+3 -3
View File
@@ -99,14 +99,14 @@
.statistics-charts { .statistics-charts {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md); gap: var(--space-md);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
@media (min-width: 768px) { @media (max-width: 767px) {
.statistics-charts { .statistics-charts {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: 1fr;
} }
} }
-1
View File
@@ -45,7 +45,6 @@ class AiUsageMonitor {
const data = await Http.getJson(`/admin/ai-usage/data?hours=${this.hours}`); const data = await Http.getJson(`/admin/ai-usage/data?hours=${this.hours}`);
this.render(data); this.render(data);
} catch { } catch {
// silently ignore poll errors
} }
} }
-4
View File
@@ -47,7 +47,6 @@ class BackupMonitor {
this.targets = data.targets || []; this.targets = data.targets || [];
this.render(data); this.render(data);
} catch { } catch {
// ignore transient poll errors
} }
} }
@@ -63,7 +62,6 @@ class BackupMonitor {
window.app.toast.show("Backup started", { type: "info" }); window.app.toast.show("Backup started", { type: "info" });
this.followJob(result.status_url); this.followJob(result.status_url);
} catch { } catch {
// Http.send already surfaced the error
} finally { } finally {
button.disabled = false; button.disabled = false;
} }
@@ -103,7 +101,6 @@ class BackupMonitor {
window.app.toast.show("Schedule saved", { type: "success" }); window.app.toast.show("Schedule saved", { type: "success" });
this.poll(); this.poll();
} catch { } catch {
// error already surfaced
} }
}); });
} }
@@ -168,7 +165,6 @@ class BackupMonitor {
await Http.send(urls[action], {}); await Http.send(urls[action], {});
this.poll(); this.poll();
} catch { } catch {
// error already surfaced
} }
}); });
} }
-1
View File
@@ -8,7 +8,6 @@ export class CodeBlock {
try { try {
hljs.highlightElement(code); hljs.highlightElement(code);
} catch { } catch {
// leave plain text on failure
} }
} }
@@ -62,8 +62,6 @@ export class ContainerInstance {
return span.innerHTML; return span.innerHTML;
} }
// ---------------- actions ----------------
renderActions(status) { renderActions(status) {
if (!this.canManage) { if (!this.canManage) {
const box = this.q("#ci-actions"); const box = this.q("#ci-actions");
@@ -107,8 +105,6 @@ export class ContainerInstance {
} catch (err) { this.toast(err.message, "error"); } } catch (err) { this.toast(err.message, "error"); }
} }
// ---------------- polling ----------------
startDetailPoll() { startDetailPoll() {
this.detailPoll = new Poller(async () => { this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`); const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);
@@ -164,8 +160,6 @@ export class ContainerInstance {
if (logs) pre.scrollTop = pre.scrollHeight; if (logs) pre.scrollTop = pre.scrollHeight;
} }
// ---------------- exec ----------------
async execOnce() { async execOnce() {
const input = this.q("#ci-exec-input"); const input = this.q("#ci-exec-input");
const cmd = input.value.trim(); const cmd = input.value.trim();
@@ -203,8 +197,6 @@ export class ContainerInstance {
.replace(/\x1b[=>]/g, ""); .replace(/\x1b[=>]/g, "");
} }
// ---------------- schedules ----------------
syncScheduleFields(kind) { syncScheduleFields(kind) {
const form = document.getElementById("ci-schedule-form"); const form = document.getElementById("ci-schedule-form");
form.querySelectorAll("[data-sched-field]").forEach((field) => { form.querySelectorAll("[data-sched-field]").forEach((field) => {
-2
View File
@@ -42,7 +42,6 @@ export class DomUtils {
await navigator.clipboard.writeText(source.textContent); await navigator.clipboard.writeText(source.textContent);
Toast.flash(btn, "Copied!", 2000); Toast.flash(btn, "Copied!", 2000);
} catch { } catch {
// silently fail
} }
}); });
} }
@@ -56,7 +55,6 @@ export class DomUtils {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
Toast.flash(btn, "Copied!", 1000); Toast.flash(btn, "Copied!", 1000);
} catch { } catch {
// silently fail
} }
}); });
} }
+1 -4
View File
@@ -287,10 +287,7 @@ export class GatewayAdmin {
} }
async remove(url) { async remove(url) {
const response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); await Http.sendDelete(url, { silent: true });
if (!response.ok && response.status !== 404) {
throw new Error(`Delete failed: ${response.status}`);
}
} }
async deleteProvider(name) { async deleteProvider(name) {
+22
View File
@@ -105,6 +105,28 @@ export class Http {
return data; return data;
} }
static async sendDelete(url, options = {}) {
const response = await fetch(url, {
method: "DELETE",
headers: { "Accept": "application/json" },
});
if (response.redirected && response.url.includes("/auth/login")) {
Http.toLogin();
return Http.suspend();
}
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
const message = (data.error && data.error.message) || `request failed: ${response.status}`;
const gate = Http._gate(data, options, () =>
Http.sendDelete(url, { ...options, termsRetry: true })
);
if (gate) return gate;
if (!options.silent) Http.notifyError(message);
throw new Error(message);
}
return data;
}
static async postJson(url, body, options = {}) { static async postJson(url, body, options = {}) {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
+2 -10
View File
@@ -46,19 +46,11 @@ export class IssueAttachments {
}); });
if (!confirmed) return; if (!confirmed) return;
try { try {
const response = await fetch(button.dataset.action, { await Http.sendDelete(button.dataset.action, { silent: true });
method: "DELETE",
headers: { "Accept": "application/json" },
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
const message = (data.error && data.error.message) || "Could not delete the attachment";
throw new Error(message);
}
const item = button.closest(".attachment-gallery-item"); const item = button.closest(".attachment-gallery-item");
if (item) item.remove(); if (item) item.remove();
} catch (error) { } catch (error) {
Http.notifyError(error.message); Http.notifyError(error.message || "Could not delete the attachment");
} }
} }
} }
-1
View File
@@ -128,7 +128,6 @@ class ServiceMonitor {
this.applyData(await Http.getJson("/admin/services/data")); this.applyData(await Http.getJson("/admin/services/data"));
} }
} catch { } catch {
// silently ignore poll errors
} }
} }
-1
View File
@@ -21,7 +21,6 @@ export class StatisticsCharts {
try { try {
chart.destroy(); chart.destroy();
} catch { } catch {
// ignore destroy errors
} }
} }
this.instances = []; this.instances = [];
@@ -96,7 +96,6 @@ export class AppDocsChat extends Component {
try { try {
await fetch("/devii/session", { credentials: "same-origin" }); await fetch("/devii/session", { credentials: "same-origin" });
} catch (error) { } catch (error) {
// Best-effort: ensures the guest cookie exists before the socket opens.
} }
this.socket = new DeviiSocket( this.socket = new DeviiSocket(
{ {
@@ -364,7 +364,6 @@ export default class ContainerTerminalElement extends FloatingWindow {
try { try {
if (this.term) this.term.dispose(); if (this.term) this.term.dispose();
} catch (error) { } catch (error) {
// already disposed
} }
this.remove(); this.remove();
} }
+28 -11
View File
@@ -2,7 +2,7 @@
retoor <retoor@molodetz.nl> retoor <retoor@molodetz.nl>
Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 331 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to. Every state-changing action in DevPlace records one append-only row through `devplacepy/services/audit/record.py` (`record` for a request-scoped actor, `record_system` for a background one). This file is the authoritative catalogue of the 355 event keys currently emitted, grouped by the category `services/audit/categories.py` `category_for` resolves them to.
## Rules ## Rules
@@ -25,19 +25,19 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `auth` | 8 | | `auth` | 8 |
| `backup` | 2 | | `backup` | 2 |
| `cli` | 43 | | `cli` | 43 |
| `container` | 36 | | `container` | 41 |
| `content` | 37 | | `content` | 37 |
| `database` | 4 | | `database` | 4 |
| `devii` | 18 | | `devii` | 18 |
| `email` | 6 | | `email` | 6 |
| `engagement` | 23 | | `engagement` | 28 |
| `game` | 6 | | `game` | 19 |
| `ingress` | 1 | | `ingress` | 1 |
| `message` | 2 | | `message` | 2 |
| `moderation` | 12 | | `moderation` | 12 |
| `news` | 9 | | `news` | 9 |
| `notification` | 4 | | `notification` | 4 |
| `project` | 11 | | `project` | 12 |
| `project_files` | 14 | | `project_files` | 14 |
| `pubsub` | 1 | | `pubsub` | 1 |
| `push` | 2 | | `push` | 2 |
@@ -48,7 +48,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `telegram` | 5 | | `telegram` | 5 |
| `tools` | 12 | | `tools` | 12 |
**Total: 331 keys.** **Total: 355 keys.**
## Account and profile (`account`) ## Account and profile (`account`)
@@ -199,10 +199,14 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `container.instance.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` | | `container.instance.create` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.instance.delete` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` | | `container.instance.delete` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.instance.exec` | `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` | | `container.instance.exec` | `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.pause` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.restart` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.resume` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.shell.close` | `routers/projects/containers/instances.py` | | `container.instance.shell.close` | `routers/projects/containers/instances.py` |
| `container.instance.shell.open` | `routers/projects/containers/instances.py` | | `container.instance.shell.open` | `routers/projects/containers/instances.py` |
| `container.instance.start` | `docs_api/groups/admin.py` | | `container.instance.start` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.status` | `services/containers/service.py` | | `container.instance.status` | `services/containers/service.py` |
| `container.instance.stop` | `routers/admin/containers.py`, `routers/projects/containers/instances.py`, `services/devii/actions/dispatcher.py` |
| `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` | | `container.instance.sync` | `routers/admin/containers.py`, `routers/projects/containers/instances.py` |
| `container.reconcile.action` | `services/containers/service.py` | | `container.reconcile.action` | `services/containers/service.py` |
| `container.tunnel.cert.failure` | `services/containers/workspace_service.py` | | `container.tunnel.cert.failure` | `services/containers/workspace_service.py` |
@@ -212,10 +216,10 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `container.tunnel.failure` | `services/containers/workspace_service.py` | | `container.tunnel.failure` | `services/containers/workspace_service.py` |
| `container.tunnel.suspend` | `services/containers/workspace_service.py` | | `container.tunnel.suspend` | `services/containers/workspace_service.py` |
| `container.workspace.create` | `routers/projects/containers/workspace.py` | | `container.workspace.create` | `routers/projects/containers/workspace.py` |
| `container.workspace.delete` | `routers/projects/containers/workspace.py` | | `container.workspace.delete` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` |
| `container.workspace.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` | | `container.workspace.editor.update` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` |
| `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` | | `container.workspace.flag.dismiss` | `routers/admin/workspaces.py` |
| `container.workspace.flag.raise` | `services/containers/workspace_service.py` | | `container.workspace.flag.raise` | `routers/admin/workspaces.py` |
| `container.workspace.flag.resolve` | `routers/admin/workspaces.py` | | `container.workspace.flag.resolve` | `routers/admin/workspaces.py` |
| `container.workspace.idle.stop` | `services/containers/workspace_service.py` | | `container.workspace.idle.stop` | `services/containers/workspace_service.py` |
| `container.workspace.idle.warn` | `services/containers/workspace_service.py` | | `container.workspace.idle.warn` | `services/containers/workspace_service.py` |
@@ -224,10 +228,10 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `container.workspace.quota.block` | `routers/projects/containers/workspace.py` | | `container.workspace.quota.block` | `routers/projects/containers/workspace.py` |
| `container.workspace.quota.warn` | `services/containers/workspace_service.py` | | `container.workspace.quota.warn` | `services/containers/workspace_service.py` |
| `container.workspace.restore` | `routers/admin/workspaces.py` | | `container.workspace.restore` | `routers/admin/workspaces.py` |
| `container.workspace.resume` | `routers/projects/containers/workspace.py` | | `container.workspace.resume` | `routers/admin/workspaces.py` |
| `container.workspace.retention.warn` | `services/containers/workspace_service.py` | | `container.workspace.retention.warn` | `services/containers/workspace_service.py` |
| `container.workspace.settings.update` | `routers/admin/workspaces.py` | | `container.workspace.settings.update` | `routers/admin/workspaces.py` |
| `container.workspace.stop` | `routers/projects/containers/workspace.py` | | `container.workspace.stop` | `routers/projects/containers/workspace.py`, `routers/admin/workspaces.py` |
| `container.workspace.suspend` | `routers/admin/workspaces.py` | | `container.workspace.suspend` | `routers/admin/workspaces.py` |
| `container.workspace.unsuspend` | `routers/admin/workspaces.py` | | `container.workspace.unsuspend` | `routers/admin/workspaces.py` |
| `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` | | `container.schedule.create` | `routers/projects/containers/schedules.py`, `services/devii/actions/dispatcher.py` |
@@ -355,12 +359,25 @@ Every state-changing action in DevPlace records one append-only row through `dev
| Event key | Recorded in | | Event key | Recorded in |
|---|---| |---|---|
| `game.ci.upgrade` | `routers/game/index.py` |
| `game.cosmetic.buy` | `routers/game/index.py` | | `game.cosmetic.buy` | `routers/game/index.py` |
| `game.cosmetic.equip` | `routers/game/index.py` |
| `game.daily.claim` | `routers/game/index.py` |
| `game.defense.downgrade` | `routers/game/index.py` | | `game.defense.downgrade` | `routers/game/index.py` |
| `game.defense.upgrade` | `routers/game/index.py` | | `game.defense.upgrade` | `routers/game/index.py` |
| `game.fertilize` | `routers/game/index.py` |
| `game.grant.claim` | `routers/game/index.py` | | `game.grant.claim` | `routers/game/index.py` |
| `game.harvest` | `routers/game/index.py` |
| `game.infrastructure.buy` | `routers/game/index.py` | | `game.infrastructure.buy` | `routers/game/index.py` |
| `game.legacy.upgrade` | `routers/game/index.py` |
| `game.mastery.upgrade` | `routers/game/index.py` |
| `game.perk.upgrade` | `routers/game/index.py` |
| `game.plant` | `routers/game/index.py` |
| `game.plot.buy` | `routers/game/index.py` |
| `game.prestige` | `routers/game/index.py` | | `game.prestige` | `routers/game/index.py` |
| `game.quest.claim` | `routers/game/index.py` |
| `game.steal` | `routers/game/farm.py` |
| `game.water` | `routers/game/farm.py` |
## Container ingress (`ingress`) ## Container ingress (`ingress`)
+5
View File
@@ -0,0 +1,5 @@
This project does contain a quiz system. When doing the quiz, it will show all questions you answered as well, but it should only show one at a time .
Also some quiz endpoint that is linked to after making the quiz shows rest response. Please find out which one (of the links) does link to a action that only responds with json., Its not appropiate.
FInd for everyting root cause and fix it.
+1
View File
@@ -0,0 +1 @@
I see when i ask for where my ai costs went that a lot went ot devii `internal`... Well, literally every ai costs has to be someones when it comes to devii. SOmeone created the action or whatnot. Please find the root and update that part perfectly. :
+75
View File
@@ -1,8 +1,13 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import json
import shutil
from datetime import datetime, timezone
import pytest import pytest
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from devplacepy.config import DBAPI_DIR
from devplacepy.services.manager import service_manager from devplacepy.services.manager import service_manager
@@ -74,3 +79,73 @@ def test_ws_non_owner_retries(client, auth):
assert info.value.code == 4013 assert info.value.code == 4013
finally: finally:
service_manager.set_lock_owner(True) service_manager.set_lock_owner(True)
def test_result_route_serves_the_persisted_rows(client, auth):
enqueued = client.post(
"/dbapi/query/async", json={"sql": "SELECT 1 AS n"}, headers=auth
)
uid = enqueued.json()["uid"]
output_dir = DBAPI_DIR / uid
output_dir.mkdir(parents=True, exist_ok=True)
result = {
"sql": "SELECT 1 AS n",
"row_count": 1,
"truncated": False,
"suspicious": [],
"rows": [{"n": 1}],
}
(output_dir / "result.json").write_text(json.dumps(result), encoding="utf-8")
try:
response = client.get(f"/dbapi/query/{uid}/result", headers=auth)
assert response.status_code == 200, response.text
assert response.json()["rows"] == [{"n": 1}]
finally:
shutil.rmtree(output_dir, ignore_errors=True)
def test_result_route_404s_without_a_written_result(client, auth):
enqueued = client.post(
"/dbapi/query/async", json={"sql": "SELECT 1 AS n"}, headers=auth
)
uid = enqueued.json()["uid"]
response = client.get(f"/dbapi/query/{uid}/result", headers=auth)
assert response.status_code == 404
def test_result_route_rejects_a_traversal_uid(client, auth):
from devplacepy.database import get_table
evil_uid = ".."
now = datetime.now(timezone.utc).isoformat()
get_table("jobs").insert(
{
"uid": evil_uid,
"kind": "dbquery",
"status": "done",
"owner_kind": "user",
"owner_id": "traversal-owner",
"preferred_name": "",
"payload": "{}",
"result": "{}",
"error": "",
"retry_count": 0,
"created_at": now,
"started_at": now,
"completed_at": now,
"updated_at": now,
"duration_ms": 0,
"last_accessed_at": "",
"expires_at": "",
"bytes_in": 0,
"bytes_out": 0,
"item_count": 0,
}
)
escape_target = DBAPI_DIR.parent / "result.json"
try:
response = client.get(f"/dbapi/query/{evil_uid}/result", headers=auth)
assert response.status_code in (400, 404), response.text
assert not escape_target.exists()
finally:
get_table("jobs").delete(uid=evil_uid)
+4
View File
@@ -61,7 +61,9 @@ def _instance(**overrides) -> dict:
def test_open_workspace_requires_enabled_setting(): def test_open_workspace_requires_enabled_setting():
set_setting("workspace_enabled", "0") set_setting("workspace_enabled", "0")
try:
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is False
finally:
set_setting("workspace_enabled", "1") set_setting("workspace_enabled", "1")
assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True assert can_open_workspace(_project(), {"uid": OWNER, "role": "Member"}) is True
@@ -98,10 +100,12 @@ def test_create_or_resume_is_idempotent():
def test_workspace_quota_blocks_beyond_limit(): def test_workspace_quota_blocks_beyond_limit():
set_setting("workspace_max_per_user", "1") set_setting("workspace_max_per_user", "1")
try:
user = {"uid": OWNER, "username": "owner"} user = {"uid": OWNER, "username": "owner"}
run_async(provision.ensure(_project("p-a"), user)) run_async(provision.ensure(_project("p-a"), user))
with pytest.raises(WorkspaceError): with pytest.raises(WorkspaceError):
run_async(provision.ensure(_project("p-b"), user)) run_async(provision.ensure(_project("p-b"), user))
finally:
set_setting("workspace_max_per_user", "2") set_setting("workspace_max_per_user", "2")
+103
View File
@@ -1,11 +1,14 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import time import time
import uuid
import requests import requests
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot from devplacepy.database import get_table, refresh_snapshot
from devplacepy.services.jobs.isslop import store
from devplacepy.utils import generate_uid
_counter_isslop = [0] _counter_isslop = [0]
@@ -192,3 +195,103 @@ def test_guest_history_claimed_on_signup(app_server):
assert get_table("isslop_analyses").count(uid=uid) == 1 assert get_table("isslop_analyses").count(uid=uid) == 1
finally: finally:
_clear_isslop_data() _clear_isslop_data()
def test_source_route_serves_the_annotated_file(app_server):
uid = generate_uid()
store.create_analysis(uid, "https://github.com/owner/repository", "guest", "src-owner")
source_name = "s" + uuid.uuid4().hex[:16] + ".txt"
store.insert_file_result(
uid,
{
"path": "app.py",
"language": "python",
"lines": 1,
"origin_score": 0.1,
"quality_deficit_score": 0.1,
"category": "human-authored",
"signals": "[]",
"source": source_name,
},
)
media_dir = store.media_dir_for(uid)
media_dir.mkdir(parents=True, exist_ok=True)
(media_dir / source_name).write_text("print('hello')\n", encoding="utf-8")
try:
r = requests.get(
f"{BASE_URL}/tools/isslop/{uid}/source",
params={"path": "app.py"},
headers=_json_headers(),
)
assert r.status_code == 200, r.text
body = r.json()
assert "print" in body["source"]
assert body["path"] == "app.py"
finally:
store.purge_analysis(uid)
def test_source_route_rejects_an_unsafe_source_token(app_server):
uid = generate_uid()
store.create_analysis(uid, "https://github.com/owner/repository", "guest", "src-traversal-owner")
store.insert_file_result(
uid,
{
"path": "app.py",
"language": "python",
"lines": 1,
"origin_score": 0.0,
"quality_deficit_score": 0.0,
"category": "human-authored",
"signals": "[]",
"source": "../../../../etc/passwd",
},
)
try:
r = requests.get(
f"{BASE_URL}/tools/isslop/{uid}/source",
params={"path": "app.py"},
headers=_json_headers(),
)
assert r.status_code == 404
finally:
store.purge_analysis(uid)
def test_media_route_serves_a_thumbnail(app_server):
uid = generate_uid()
store.create_analysis(uid, "https://github.com/owner/repository", "guest", "media-owner")
name = uuid.uuid4().hex[:16] + ".webp"
store.insert_image_result(
uid,
{
"path": "assets/hero.png",
"ai_probability": 0.2,
"grade": "n/a",
"verdict": "uncertain",
"image_kind": "image",
"tells": "[]",
"description": "",
"thumb": name,
},
)
media_dir = store.media_dir_for(uid)
media_dir.mkdir(parents=True, exist_ok=True)
(media_dir / name).write_bytes(b"not-a-real-webp-but-bytes")
try:
r = requests.get(f"{BASE_URL}/tools/isslop/{uid}/media/{name}")
assert r.status_code == 200, r.text
assert r.headers["content-type"].startswith("image/webp")
assert r.content == b"not-a-real-webp-but-bytes"
finally:
store.purge_analysis(uid)
def test_media_route_rejects_a_name_outside_the_hex_pattern(app_server):
uid = generate_uid()
store.create_analysis(uid, "https://github.com/owner/repository", "guest", "media-traversal-owner")
try:
r = requests.get(f"{BASE_URL}/tools/isslop/{uid}/media/..%2fetc%2fpasswd")
assert r.status_code == 404, r.text
finally:
store.purge_analysis(uid)
+3 -3
View File
@@ -77,7 +77,7 @@ def test_formdata_list_field_mixed_empty_and_real():
def test_formdata_literal_fields_are_scalar(): def test_formdata_literal_fields_are_scalar():
"""Literal-typed fields (e.g. target_type in CommentForm) are treated as """Literal-typed fields (e.g. target_type in CommentForm) are treated as
scalars the origin check must not crash on typing.Literal.""" scalars - the origin check must not crash on typing.Literal."""
fd = FormData([ fd = FormData([
("content", "Great comment here!"), ("content", "Great comment here!"),
("target_type", "post"), ("target_type", "post"),
@@ -108,14 +108,14 @@ def test_formdata_literal_form_from_browser():
def test_formdata_vote_form(): def test_formdata_vote_form():
"""value: int field with form data string '1' is kept for Pydantic coerce.""" """value: int field with form data - string '1' is kept for Pydantic coerce."""
fd = FormData([("value", "1")]) fd = FormData([("value", "1")])
body = _formdata_to_dict(fd, VoteForm) body = _formdata_to_dict(fd, VoteForm)
assert body["value"] == "1" # Pydantic coerces str→int assert body["value"] == "1" # Pydantic coerces str→int
def test_formdata_issue_status_literal(): def test_formdata_issue_status_literal():
"""IssueStatusForm has Literal['open','closed'] must not crash.""" """IssueStatusForm has Literal['open','closed'] - must not crash."""
fd = FormData([("status", "open")]) fd = FormData([("status", "open")])
body = _formdata_to_dict(fd, IssueStatusForm) body = _formdata_to_dict(fd, IssueStatusForm)
assert body["status"] == "open" assert body["status"] == "open"
+5 -5
View File
@@ -81,7 +81,7 @@ def test_issue_token_returns_expected_fields(local_db):
def test_resolve_token_valid(local_db): def test_resolve_token_valid(local_db):
"""Issue a token then resolve it returns the correct user.""" """Issue a token then resolve it - returns the correct user."""
uid = _seed_user("resolver", "resolver@test.dev") uid = _seed_user("resolver", "resolver@test.dev")
user = get_table("users").find_one(uid=uid) user = get_table("users").find_one(uid=uid)
@@ -239,10 +239,10 @@ def test_resolve_user_access_token_priority_after_api_key(local_db):
"""api_key user wins over access token when the same string matches both.""" """api_key user wins over access token when the same string matches both."""
shared_key = secrets.token_hex(32) # 64-char hex shared_key = secrets.token_hex(32) # 64-char hex
# User A owns the api_key # User A - owns the api_key
uid_a = _seed_user("apikey_winner", "apikeywinner@test.dev", api_key=shared_key) uid_a = _seed_user("apikey_winner", "apikeywinner@test.dev", api_key=shared_key)
# User B owns an access token with the same string as the key # User B - owns an access token with the same string as the key
uid_b = _seed_user("acctok_loser", "acctokloser@test.dev") uid_b = _seed_user("acctok_loser", "acctokloser@test.dev")
get_table("access_tokens").insert( get_table("access_tokens").insert(
{ {
@@ -257,14 +257,14 @@ def test_resolve_user_access_token_priority_after_api_key(local_db):
} }
) )
# X-API-KEY path api_key user wins # X-API-KEY path - api_key user wins
request = _MockRequest(headers={"X-API-KEY": shared_key}) request = _MockRequest(headers={"X-API-KEY": shared_key})
resolved = _resolve_user(request) resolved = _resolve_user(request)
assert resolved is not None assert resolved is not None
assert resolved["uid"] == uid_a assert resolved["uid"] == uid_a
assert resolved["username"] == "apikey_winner" assert resolved["username"] == "apikey_winner"
# Bearer path api_key user wins # Bearer path - api_key user wins
request2 = _MockRequest(headers={"Authorization": f"Bearer {shared_key}"}) request2 = _MockRequest(headers={"Authorization": f"Bearer {shared_key}"})
resolved2 = _resolve_user(request2) resolved2 = _resolve_user(request2)
assert resolved2 is not None assert resolved2 is not None
+6 -6
View File
@@ -68,7 +68,7 @@ def _seed_token(user_uid, key, expire_time):
# ── resolve_user_by_key tests ──────────────────────────────────────────────── # ── resolve_user_by_key tests ────────────────────────────────────────────────
def test_resolve_user_by_key_valid(local_db): def test_resolve_user_by_key_valid(local_db):
"""Create a devrant token for a user, then resolve it by key the right user is returned.""" """Create a devrant token for a user, then resolve it by key - the right user is returned."""
uid = _seed_user("dr_valid", "drvalid@test.dev") uid = _seed_user("dr_valid", "drvalid@test.dev")
token_key = secrets.token_hex(20) token_key = secrets.token_hex(20)
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400 expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
@@ -81,13 +81,13 @@ def test_resolve_user_by_key_valid(local_db):
def test_resolve_user_by_key_not_found(local_db): def test_resolve_user_by_key_not_found(local_db):
"""Look up a random key that has no token verify None.""" """Look up a random key that has no token - verify None."""
random_key = secrets.token_hex(20) random_key = secrets.token_hex(20)
assert resolve_user_by_key(random_key) is None assert resolve_user_by_key(random_key) is None
def test_resolve_user_by_key_expired(local_db): def test_resolve_user_by_key_expired(local_db):
"""Create an expired token resolve returns None.""" """Create an expired token - resolve returns None."""
uid = _seed_user("dr_expired", "drexpired@test.dev") uid = _seed_user("dr_expired", "drexpired@test.dev")
token_key = secrets.token_hex(20) token_key = secrets.token_hex(20)
expire_time = int(datetime.now(timezone.utc).timestamp()) - 86400 expire_time = int(datetime.now(timezone.utc).timestamp()) - 86400
@@ -97,7 +97,7 @@ def test_resolve_user_by_key_expired(local_db):
def test_resolve_user_by_key_inactive_user(local_db): def test_resolve_user_by_key_inactive_user(local_db):
"""Create a token for an inactive user resolve returns None.""" """Create a token for an inactive user - resolve returns None."""
uid = _seed_user("dr_inactive", "drinactive@test.dev", is_active=False) uid = _seed_user("dr_inactive", "drinactive@test.dev", is_active=False)
token_key = secrets.token_hex(20) token_key = secrets.token_hex(20)
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400 expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
@@ -162,10 +162,10 @@ def test_resolve_user_prefers_api_key_over_devrant(local_db):
"""If a key matches both an api_key and a devrant token, the api_key user wins.""" """If a key matches both an api_key and a devrant token, the api_key user wins."""
shared_key = secrets.token_hex(20) shared_key = secrets.token_hex(20)
# User A owns the api_key # User A - owns the api_key
uid_a = _seed_user("api_key_owner", "apikey@test.dev", api_key=shared_key) uid_a = _seed_user("api_key_owner", "apikey@test.dev", api_key=shared_key)
# User B owns the devrant token with the same key # User B - owns the devrant token with the same key
uid_b = _seed_user("devrant_owner", "drowner@test.dev") uid_b = _seed_user("devrant_owner", "drowner@test.dev")
expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400 expire_time = int(datetime.now(timezone.utc).timestamp()) + 86400
_seed_token(uid_b, shared_key, expire_time) _seed_token(uid_b, shared_key, expire_time)