# retoor <retoor@molodetz.nl>

from devplacepy import schemas
from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.docs_examples import schema_example, action_example

VOTE_TARGETS = ["post", "comment", "gist", "project"]
REACTION_TARGETS = ["post", "comment", "gist", "project"]
BOOKMARK_TARGETS = ["post", "gist", "project", "news"]
COMMENT_TARGETS = ["post", "project", "news", "issue", "gist"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = [
    "python",
    "javascript",
    "typescript",
    "html",
    "css",
    "c",
    "cpp",
    "java",
    "go",
    "rust",
    "sql",
    "bash",
    "json",
    "markdown",
    "plaintext",
]
SERVICE_ACTIONS = [
    ("start", "Start the service"),
    ("stop", "Stop the service"),
    ("run", "Trigger a single run now"),
    ("clear-logs", "Clear the in-memory log buffer"),
]


def field(
    name,
    location,
    type="string",
    required=False,
    example="",
    description="",
    options=None,
):
    spec = {
        "name": name,
        "location": location,
        "type": type,
        "required": required,
        "example": example,
        "description": description,
    }
    if options:
        spec["options"] = list(options)
    return spec


ROLE_LABELS = {"public": "Public", "user": "Member", "admin": "Admin"}


def endpoint(
    id,
    method,
    path,
    title,
    summary,
    auth="user",
    ajax=False,
    encoding="none",
    interactive=True,
    destructive=False,
    params=None,
    notes=None,
    sample_response=None,
    negotiation=None,
):
    return {
        "id": id,
        "method": method,
        "path": path,
        "title": title,
        "summary": summary,
        "auth": auth,
        "min_role": ROLE_LABELS.get(auth, auth.title()),
        "ajax": ajax,
        "encoding": encoding,
        "interactive": interactive,
        "destructive": destructive,
        "params": params or [],
        "notes": notes or [],
        "sample_response": sample_response,
        "negotiation": negotiation,
    }


NON_BODY_ENDPOINTS = {
    "avatar",
    "gateway-passthrough",
    "notifications-open",
    "admin-bots-frame",
}


def _classify(ep):
    if ep["id"] in _PAGE_RESPONSES or ep["id"] in _ACTION_RESPONSES:
        return "negotiable"
    if ep.get("ajax"):
        return "ajax"
    if ep["id"] in NON_BODY_ENDPOINTS:
        return "none"
    return "json"


def _service_control_endpoints():
    endpoints = []
    for action, summary in SERVICE_ACTIONS:
        endpoints.append(
            endpoint(
                id=f"services-{action.replace('-', '')}",
                method="POST",
                path=f"/admin/services/{{name}}/{action}",
                title=f"Service: {action}",
                summary=summary + ".",
                auth="admin",
                interactive=True,
                destructive=True,
                params=[
                    field(
                        "name",
                        "path",
                        required=True,
                        example="news",
                        description="Registered service name.",
                    )
                ],
                sample_response={"ok": True},
            )
        )
    return endpoints


API_GROUPS = [
    {
        "slug": "conventions",
        "title": "Conventions & Errors",
        "intro": """
# Conventions & Errors

Shared rules that apply to every endpoint in this reference.

## Base URL

Every example uses your current host:

```
{{ base }}
```

## Authentication

Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
endpoints with any of the four methods in [Authentication](/docs/authentication.html): the
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
interactive panels on this site pre-fill your own API key, so you can run user-level calls
immediately.

## Request bodies

POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
a JSON body; those are noted explicitly.

## HTML or JSON (content negotiation)

**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
website keeps working exactly as before, and automation gets structured data from the same
URLs. A request is served JSON when it sends either of:

- `Accept: application/json`
- `Content-Type: application/json`

A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
changes. Responses are defined by Pydantic models, so each page returns the same data the
template renders.

**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
for pagination; detail pages embed author, comments, reactions, poll, and attachments).

**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
uniform envelope instead of a `302` redirect:

```json
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
```

`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
session set on login/signup) are still set on JSON responses.

**Errors** are JSON too when JSON is requested:

```json
{ "error": { "status": 404, "message": "Not found" } }
```

Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
the login page instead); non-admins calling an admin endpoint get `403`.

## Trying it here

Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
modeled response shape; the **Live response** tab shows the real result after you press
**Send request**.

## AJAX responses (legacy shape)

The [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html) endpoints predate the
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
header for you.

## Pagination

Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
post/project/gist/news lists, notifications, and saved bookmarks.

The follower and following lists are the exception: `GET /profile/{username}/followers` and
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
parameter (25 per page), not a cursor.

## Identifiers

Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
path. Slugs embed the first eight characters of the UUID.

## Dates

All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.

## Status codes

| Code | Meaning |
|------|---------|
| `200` | Success (JSON or HTML) |
| `201` | Resource created (uploads) |
| `302` | Redirect (browser-style success for form posts) |
| `400` | Invalid request body or parameters |
| `401` | Credentials supplied but invalid |
| `403` | Authenticated but not allowed |
| `404` | Resource not found |
| `413` | Upload exceeds the configured size limit (see [Uploads](/docs/uploads.html)) |
| `415` | Upload file type not allowed (see [Uploads](/docs/uploads.html)) |
| `422` | Form/body validation failed (JSON clients) |
| `429` | Rate limit exceeded (see Rate limiting) |
| `503` | Maintenance mode |

## Rate limiting

Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
rolling window; reads are not limited. The limit and window are configurable by an
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
OpenAI gateway (`/openai/...`) is exempt.

## Troubleshooting

**I get HTML back instead of JSON.** Send `Accept: application/json` (or
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
and does not also accept `text/html`; a normal browser navigation always gets HTML.
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
engagement actions (votes, reactions, bookmarks, polls).

**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
for JSON. Add the `Accept: application/json` header and the action returns
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.

**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
get `403` (JSON) or a redirect to the feed (HTML).

**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
administrator.
""",
        "endpoints": [],
    },
    {
        "slug": "auth",
        "title": "Authentication",
        "intro": """
# Authentication

Create an account, sign in, recover your password, and log out. These are the only endpoints
that set or clear the `session` cookie; every other request authenticates with the methods
described in [Authentication](/docs/authentication.html). The shared rules (content
negotiation, pagination, status codes) live in [Conventions and Errors](/docs/conventions.html).

## Page vs. action

The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
page data as JSON when requested with `Accept: application/json` (including `page` to
distinguish the form type).

The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
and return a `302` redirect (or the JSON envelope for JSON callers).

**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
""",
        "endpoints": [
            endpoint(
                id="auth-signup",
                method="GET",
                path="/auth/signup",
                title="Sign up page",
                summary="Render the registration form. Returns an HTML page.",
                auth="public",
                interactive=True,
            ),
            endpoint(
                id="auth-signup-post",
                method="POST",
                path="/auth/signup",
                title="Sign up",
                summary="Create a new account. Sets the session cookie on success.",
                auth="public",
                encoding="form",
                destructive=False,
                params=[
                    field("username", "form", "string", True, "alice", "Username, 3-20 characters."),
                    field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
                    field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
                    field("g-recaptcha-response", "form", "string", False, "", "reCAPTCHA token when enabled."),
                ],
            ),
            endpoint(
                id="auth-login",
                method="GET",
                path="/auth/login",
                title="Log in page",
                summary="Render the login form. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field("next", "query", "string", False, "", "Redirect target after login."),
                ],
            ),
            endpoint(
                id="auth-login-post",
                method="POST",
                path="/auth/login",
                title="Log in",
                summary="Authenticate with username and password. Sets the session cookie.",
                auth="public",
                encoding="form",
                params=[
                    field("username", "form", "string", True, "alice", "Your username."),
                    field("password", "form", "string", True, "mysecret", "Your password."),
                    field("next", "form", "string", False, "", "Redirect target after login."),
                ],
            ),
            endpoint(
                id="auth-forgot-password",
                method="GET",
                path="/auth/forgot-password",
                title="Forgot password page",
                summary="Render the forgot-password form. Returns an HTML page.",
                auth="public",
                interactive=True,
            ),
            endpoint(
                id="auth-forgot-password-post",
                method="POST",
                path="/auth/forgot-password",
                title="Request password reset",
                summary="Send a password-reset email with a one-time link.",
                auth="public",
                encoding="form",
                params=[
                    field("email", "form", "string", True, "alice@example.com", "Your registered email."),
                ],
            ),
            endpoint(
                id="auth-reset-password",
                method="GET",
                path="/auth/reset-password/{token}",
                title="Reset password page",
                summary="Render the password-reset form (only valid with a one-time token). Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
                ],
            ),
            endpoint(
                id="auth-reset-password-post",
                method="POST",
                path="/auth/reset-password/{token}",
                title="Reset password",
                summary="Set a new password using a one-time reset token.",
                auth="public",
                encoding="form",
                params=[
                    field("token", "path", "string", True, "RESET_TOKEN", "The one-time reset token from the email."),
                    field("password", "form", "string", True, "newpass", "New password, 6+ characters."),
                    field("confirm_password", "form", "string", True, "newpass", "Must match password."),
                ],
            ),
            endpoint(
                id="auth-logout",
                method="GET",
                path="/auth/logout",
                title="Log out",
                summary="Clear the session cookie and redirect to the landing page.",
                auth="public",
                interactive=False,
            ),
        ],
    },
    {
        "slug": "lookups",
        "title": "Search & Lookups",
        "intro": """
# Search & Lookups

Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
a single `q` query parameter. These feed [Messaging](/docs/messaging.html) (the recipient
composer) and [Profiles & Social Graph](/docs/profiles.html) (mentions and user pages).

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="search-users",
                method="GET",
                path="/profile/search",
                title="Search users",
                summary="Find up to ten users whose username matches a query.",
                auth="user",
                params=[
                    field(
                        "q",
                        "query",
                        required=True,
                        example="al",
                        description="Partial username to match.",
                    )
                ],
                sample_response={
                    "results": [{"uid": "8f14e45f-...", "username": "alice_test"}]
                },
            ),
            endpoint(
                id="search-message-recipients",
                method="GET",
                path="/messages/search",
                title="Search message recipients",
                summary="Like user search, but excludes yourself; used by the message composer.",
                auth="user",
                params=[
                    field(
                        "q",
                        "query",
                        required=True,
                        example="bo",
                        description="Partial username to match.",
                    )
                ],
                sample_response={
                    "results": [{"uid": "0cc175b9-...", "username": "bob_test"}]
                },
            ),
        ],
    },
    {
        "slug": "social-actions",
        "title": "Votes, Reactions, Bookmarks & Polls",
        "intro": """
# Votes, Reactions, Bookmarks & Polls

Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
automatically by the panels below); the [Conventions & Errors](/docs/conventions.html) page
explains that header rule and the response envelope.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="votes-cast",
                method="POST",
                path="/votes/{target_type}/{target_uid}",
                title="Cast or toggle a vote",
                summary="Upvote or downvote a target. Re-sending the same value removes the vote.",
                auth="user",
                ajax=True,
                encoding="form",
                params=[
                    field(
                        "target_type",
                        "path",
                        "enum",
                        True,
                        "post",
                        "Type of content being voted on.",
                        VOTE_TARGETS,
                    ),
                    field(
                        "target_uid",
                        "path",
                        "string",
                        True,
                        "POST_UID",
                        "UID of the target.",
                    ),
                    field(
                        "value",
                        "form",
                        "enum",
                        True,
                        "1",
                        "1 to upvote, -1 to downvote.",
                        ["1", "-1"],
                    ),
                ],
                sample_response={"net": 3, "up": 4, "down": 1, "value": 1},
            ),
            endpoint(
                id="reactions-toggle",
                method="POST",
                path="/reactions/{target_type}/{target_uid}",
                title="Toggle an emoji reaction",
                summary="Add or remove an emoji reaction on a target.",
                auth="user",
                ajax=True,
                encoding="form",
                params=[
                    field(
                        "target_type",
                        "path",
                        "enum",
                        True,
                        "post",
                        "Type of content being reacted to.",
                        REACTION_TARGETS,
                    ),
                    field(
                        "target_uid",
                        "path",
                        "string",
                        True,
                        "POST_UID",
                        "UID of the target.",
                    ),
                    field(
                        "emoji",
                        "form",
                        "enum",
                        True,
                        REACTION_EMOJI[0],
                        "One of the allowed reaction emoji.",
                        REACTION_EMOJI,
                    ),
                ],
                sample_response={
                    "counts": {REACTION_EMOJI[0]: 2},
                    "mine": [REACTION_EMOJI[0]],
                },
            ),
            endpoint(
                id="bookmarks-toggle",
                method="POST",
                path="/bookmarks/{target_type}/{target_uid}",
                title="Toggle a bookmark",
                summary="Save or unsave a target to your bookmarks.",
                auth="user",
                ajax=True,
                encoding="none",
                params=[
                    field(
                        "target_type",
                        "path",
                        "enum",
                        True,
                        "post",
                        "Type of content to bookmark.",
                        BOOKMARK_TARGETS,
                    ),
                    field(
                        "target_uid",
                        "path",
                        "string",
                        True,
                        "POST_UID",
                        "UID of the target.",
                    ),
                ],
                sample_response={"saved": True},
            ),
            endpoint(
                id="bookmarks-saved",
                method="GET",
                path="/bookmarks/saved",
                title="View saved bookmarks",
                summary="Render your saved content. Returns an HTML page.",
                auth="user",
                interactive=True,
                params=[
                    field(
                        "before",
                        "query",
                        "string",
                        False,
                        "",
                        "Pagination cursor (created_at of the last item).",
                    )
                ],
                notes=[
                    "Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](/docs/content.html)."
                ],
            ),
            endpoint(
                id="polls-vote",
                method="POST",
                path="/polls/{poll_uid}/vote",
                title="Vote in a poll",
                summary="Cast, change, or clear your vote on a poll option.",
                auth="user",
                ajax=True,
                encoding="form",
                params=[
                    field(
                        "poll_uid",
                        "path",
                        "string",
                        True,
                        "POLL_UID",
                        "UID of the poll.",
                    ),
                    field(
                        "option_uid",
                        "form",
                        "string",
                        True,
                        "OPTION_UID",
                        "UID of the chosen option.",
                    ),
                ],
                notes=[
                    "You hold at most one vote per poll, and only your latest vote counts. "
                    "Voting a different option replaces your previous choice; voting your current "
                    "option again removes the vote.",
                ],
                sample_response={
                    "question": "Best editor?",
                    "options": [{"uid": "OPTION_UID", "label": "Vim", "votes": 5}],
                    "total": 5,
                    "voted": "OPTION_UID",
                },
            ),
        ],
    },
    {
        "slug": "content",
        "title": "Posts, Comments, Projects, Gists & News",
        "intro": """
# Posts, Comments, Projects, Gists & News

The core content types. Read endpoints render HTML pages; write endpoints accept form fields
and redirect to the new or updated resource. List fields such as `attachment_uids` are
repeated form keys - upload files first via [Uploads](/docs/uploads.html) and pass the returned
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](/docs/social-actions.html).

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="home",
                method="GET",
                path="/",
                title="Home",
                summary=(
                    "The home page. Guests see the marketing splash; authenticated users see a "
                    "personalized home (welcome, feed shortcut, latest posts, news). It no longer "
                    "redirects to /feed. The latest-posts section interleaves authors so no two consecutive posts share an author."
                ),
                auth="public",
                interactive=True,
            ),
            endpoint(
                id="feed-list",
                method="GET",
                path="/feed",
                title="Browse the feed",
                summary="The main post feed. Returns an HTML page. Each page interleaves authors so no two consecutive posts share an author.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "tab",
                        "query",
                        "enum",
                        False,
                        "all",
                        "Feed selector.",
                        ["all", "trending", "following"],
                    ),
                    field(
                        "topic", "query", "enum", False, "", "Filter by topic.", TOPICS
                    ),
                    field(
                        "search",
                        "query",
                        "string",
                        False,
                        "",
                        "Search post title and content.",
                    ),
                    field("before", "query", "string", False, "", "Pagination cursor."),
                ],
            ),
            endpoint(
                id="posts-create",
                method="POST",
                path="/posts/create",
                title="Create a post",
                summary="Publish a post, optionally with a poll. Redirects to the new post.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "Posted from a script.",
                        "Body, 10-125000 characters.",
                    ),
                    field(
                        "title",
                        "form",
                        "string",
                        False,
                        "Hello",
                        "Optional title, up to 500 characters.",
                    ),
                    field(
                        "topic", "form", "enum", False, "random", "Post topic.", TOPICS
                    ),
                    field(
                        "project_uid",
                        "form",
                        "string",
                        False,
                        "",
                        "Attach to a project.",
                    ),
                    field(
                        "poll_question",
                        "form",
                        "string",
                        False,
                        "",
                        "Optional poll question.",
                    ),
                    field(
                        "poll_options",
                        "form",
                        "string",
                        False,
                        "",
                        "Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
                    ),
                ],
                notes=["Returns a `302` redirect to `/posts/{slug}` on success."],
            ),
            endpoint(
                id="posts-detail",
                method="GET",
                path="/posts/{post_slug}",
                title="View a post",
                summary="Render a post with comments. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "post_slug",
                        "path",
                        "string",
                        True,
                        "POST_SLUG",
                        "Slug or UID of the post.",
                    )
                ],
            ),
            endpoint(
                id="posts-edit",
                method="POST",
                path="/posts/edit/{post_slug}",
                title="Edit a post",
                summary="Update a post you own, optionally adding a poll if it has none.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "post_slug",
                        "path",
                        "string",
                        True,
                        "POST_SLUG",
                        "Slug or UID of the post.",
                    ),
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "Updated body.",
                        "Body, 10-125000 characters.",
                    ),
                    field(
                        "title",
                        "form",
                        "string",
                        False,
                        "Updated title",
                        "Optional title.",
                    ),
                    field(
                        "topic", "form", "enum", False, "random", "Post topic.", TOPICS
                    ),
                    field(
                        "poll_question",
                        "form",
                        "string",
                        False,
                        "",
                        "Optional poll question. Adds a poll only when the post has none.",
                    ),
                    field(
                        "poll_options",
                        "form",
                        "string",
                        False,
                        "",
                        "Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options).",
                    ),
                ],
            ),
            endpoint(
                id="posts-delete",
                method="POST",
                path="/posts/delete/{post_slug}",
                title="Delete a post",
                summary="Delete a post you own; administrators may delete any user's post. Soft-deleted (hidden everywhere but restorable from admin trash) and cascades its comments and votes.",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "post_slug",
                        "path",
                        "string",
                        True,
                        "POST_SLUG",
                        "Slug or UID of the post.",
                    )
                ],
            ),
            endpoint(
                id="comments-create",
                method="POST",
                path="/comments/create",
                title="Create a comment",
                summary="Comment on any commentable target. Supports nested replies.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "Nice work.",
                        "Body, 3-1000 characters.",
                    ),
                    field(
                        "target_uid",
                        "form",
                        "string",
                        False,
                        "POST_UID",
                        "UID of the target (or use post_uid).",
                    ),
                    field(
                        "post_uid",
                        "form",
                        "string",
                        False,
                        "",
                        "Convenience alias for a post target.",
                    ),
                    field(
                        "target_type",
                        "form",
                        "enum",
                        False,
                        "post",
                        "Type of the target.",
                        COMMENT_TARGETS,
                    ),
                    field(
                        "parent_uid",
                        "form",
                        "string",
                        False,
                        "",
                        "Parent comment UID for a reply.",
                    ),
                ],
                notes=["Either `target_uid` or `post_uid` is required."],
            ),
            endpoint(
                id="comments-edit",
                method="POST",
                path="/comments/edit/{comment_uid}",
                title="Edit a comment",
                summary="Edit the body of a comment you own. Returns the updated comment.",
                auth="user",
                encoding="form",
                params=[
                    field(
                        "comment_uid",
                        "path",
                        "string",
                        True,
                        "COMMENT_UID",
                        "UID of the comment.",
                    ),
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "Edited body.",
                        "New body, 3-1000 characters.",
                    ),
                ],
                sample_response={
                    "uid": "COMMENT_UID",
                    "content": "Edited body.",
                    "url": "/posts/POST_SLUG#comment-COMMENT_UID",
                    "updated_at": "2026-06-15T12:00:00+00:00",
                },
            ),
            endpoint(
                id="comments-delete",
                method="POST",
                path="/comments/delete/{comment_uid}",
                title="Delete a comment",
                summary="Delete a comment you own; administrators may delete any user's comment. Soft-deleted (hidden everywhere but restorable from admin trash).",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "comment_uid",
                        "path",
                        "string",
                        True,
                        "COMMENT_UID",
                        "UID of the comment.",
                    )
                ],
            ),
            endpoint(
                id="projects-list",
                method="GET",
                path="/projects",
                title="Browse projects",
                summary="List projects. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "tab",
                        "query",
                        "enum",
                        False,
                        "recent",
                        "Sort selector.",
                        ["recent", "popular", "released"],
                    ),
                    field(
                        "search",
                        "query",
                        "string",
                        False,
                        "",
                        "Search title and description.",
                    ),
                    field(
                        "project_type",
                        "query",
                        "enum",
                        False,
                        "",
                        "Filter by type.",
                        PROJECT_TYPES,
                    ),
                    field("before", "query", "string", False, "", "Pagination cursor."),
                ],
            ),
            endpoint(
                id="projects-detail",
                method="GET",
                path="/projects/{project_slug}",
                title="View a project",
                summary="Render a project with comments. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Slug or UID of the project.",
                    )
                ],
            ),
            endpoint(
                id="projects-create",
                method="POST",
                path="/projects/create",
                title="Create a project",
                summary="Publish a project. Redirects to the new project.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "title",
                        "form",
                        "string",
                        True,
                        "My Project",
                        "Title, 1-200 characters.",
                    ),
                    field(
                        "description",
                        "form",
                        "textarea",
                        True,
                        "What it does.",
                        "Description, 1-5000 characters.",
                    ),
                    field(
                        "project_type",
                        "form",
                        "enum",
                        False,
                        "software",
                        "Project type.",
                        PROJECT_TYPES,
                    ),
                    field(
                        "status",
                        "form",
                        "string",
                        False,
                        "In Development",
                        "Free-form status label.",
                    ),
                    field(
                        "platforms",
                        "form",
                        "string",
                        False,
                        "Linux, Web",
                        "Comma-separated platforms.",
                    ),
                    field(
                        "release_date",
                        "form",
                        "string",
                        False,
                        "31/12/2026",
                        "Optional release date in DD/MM/YYYY format.",
                    ),
                    field(
                        "demo_date",
                        "form",
                        "string",
                        False,
                        "31/12/2026",
                        "Optional demo date in DD/MM/YYYY format.",
                    ),
                ],
            ),
            endpoint(
                id="projects-edit",
                method="POST",
                path="/projects/edit/{project_slug}",
                title="Edit a project",
                summary="Update an owned project. Redirects to the project.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "my-project-1a2b3c4d",
                        "Project slug or uid.",
                    ),
                    field(
                        "title",
                        "form",
                        "string",
                        True,
                        "My Project",
                        "Title, 1-200 characters.",
                    ),
                    field(
                        "description",
                        "form",
                        "textarea",
                        True,
                        "What it does.",
                        "Description, 1-5000 characters.",
                    ),
                    field(
                        "project_type",
                        "form",
                        "enum",
                        False,
                        "software",
                        "Project type.",
                        PROJECT_TYPES,
                    ),
                    field(
                        "status",
                        "form",
                        "string",
                        False,
                        "In Development",
                        "Free-form status label.",
                    ),
                    field(
                        "platforms",
                        "form",
                        "string",
                        False,
                        "Linux, Web",
                        "Comma-separated platforms.",
                    ),
                    field(
                        "release_date",
                        "form",
                        "string",
                        False,
                        "31/12/2026",
                        "Optional release date in DD/MM/YYYY format.",
                    ),
                    field(
                        "demo_date",
                        "form",
                        "string",
                        False,
                        "31/12/2026",
                        "Optional demo date in DD/MM/YYYY format.",
                    ),
                ],
            ),
            endpoint(
                id="projects-delete",
                method="POST",
                path="/projects/delete/{project_slug}",
                title="Delete a project",
                summary="Delete a project you own; administrators may delete any user's project. Soft-deleted with all of its files (hidden everywhere but restorable from admin trash).",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Slug or UID of the project.",
                    )
                ],
            ),
            endpoint(
                id="projects-private",
                method="POST",
                path="/projects/{project_slug}/private",
                title="Set project visibility",
                summary="Mark a project you own private (only you and administrators can see it) or public. Send value=1 for private, value=0 for public.",
                auth="user",
                encoding="form",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Slug or UID of the project.",
                    ),
                    field(
                        "value",
                        "form",
                        "boolean",
                        True,
                        "1",
                        "1 to make the project private, 0 to make it public.",
                    ),
                ],
            ),
            endpoint(
                id="projects-readonly",
                method="POST",
                path="/projects/{project_slug}/readonly",
                title="Set project read-only",
                summary="Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Slug or UID of the project.",
                    ),
                    field(
                        "value",
                        "form",
                        "boolean",
                        True,
                        "1",
                        "1 to make the project read-only, 0 to make it writable.",
                    ),
                ],
            ),
            endpoint(
                id="projects-zip",
                method="POST",
                path="/projects/{project_slug}/zip",
                title="Queue a project zip",
                summary="Start a background job that archives the whole project. Returns the job uid and status URL to poll.",
                auth="public",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Slug or UID of the project.",
                    )
                ],
                sample_response={
                    "uid": "ZIP_JOB_UID",
                    "status_url": "/zips/ZIP_JOB_UID",
                },
            ),
            endpoint(
                id="zips-status",
                method="GET",
                path="/zips/{uid}",
                title="Zip job status",
                summary="Poll a zip job. While pending or running download_url is null; once done it points at the archive.",
                auth="public",
                params=[
                    field(
                        "uid",
                        "path",
                        "string",
                        True,
                        "ZIP_JOB_UID",
                        "Zip job uid returned when the job was queued.",
                    )
                ],
                sample_response={
                    "uid": "ZIP_JOB_UID",
                    "kind": "zip",
                    "status": "done",
                    "preferred_name": "my-project",
                    "download_url": "/zips/ZIP_JOB_UID/download",
                    "error": None,
                    "bytes_in": 20480,
                    "bytes_out": 8192,
                    "item_count": 12,
                    "file_count": 10,
                    "dir_count": 2,
                    "created_at": "2026-06-09T10:00:00+00:00",
                    "completed_at": "2026-06-09T10:00:03+00:00",
                },
            ),
            endpoint(
                id="zips-download",
                method="GET",
                path="/zips/{uid}/download",
                title="Download a zip archive",
                summary="Stream the finished archive as application/zip. Each access extends the retention window.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "uid",
                        "path",
                        "string",
                        True,
                        "ZIP_JOB_UID",
                        "Zip job uid of a finished job.",
                    )
                ],
            ),
            endpoint(
                id="projects-fork",
                method="POST",
                path="/projects/{project_slug}/fork",
                title="Queue a project fork",
                summary="Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.",
                auth="user",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Slug or UID of the project to fork.",
                    ),
                    field(
                        "title",
                        "form",
                        "string",
                        True,
                        "My Fork",
                        "Title for the new forked project.",
                    ),
                ],
                sample_response={
                    "uid": "FORK_JOB_UID",
                    "status_url": "/forks/FORK_JOB_UID",
                },
            ),
            endpoint(
                id="forks-status",
                method="GET",
                path="/forks/{uid}",
                title="Fork job status",
                summary="Poll a fork job. While pending or running project_url is null; once done it points at the new project.",
                auth="public",
                params=[
                    field(
                        "uid",
                        "path",
                        "string",
                        True,
                        "FORK_JOB_UID",
                        "Fork job uid returned when the job was queued.",
                    )
                ],
                sample_response={
                    "uid": "FORK_JOB_UID",
                    "kind": "fork",
                    "status": "done",
                    "preferred_name": "My Fork",
                    "project_uid": "NEW_PROJECT_UID",
                    "project_url": "/projects/new-project-slug",
                    "source_project_uid": "SOURCE_PROJECT_UID",
                    "error": None,
                    "item_count": 12,
                    "created_at": "2026-06-09T10:00:00+00:00",
                    "completed_at": "2026-06-09T10:00:05+00:00",
                },
            ),
            endpoint(
                id="gists-list",
                method="GET",
                path="/gists",
                title="Browse gists",
                summary="List code gists. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "language",
                        "query",
                        "enum",
                        False,
                        "",
                        "Filter by language.",
                        GIST_LANGUAGES,
                    ),
                    field(
                        "user_uid",
                        "query",
                        "string",
                        False,
                        "",
                        "Filter by author UID.",
                    ),
                    field(
                        "search",
                        "query",
                        "string",
                        False,
                        "",
                        "Search gist title and description.",
                    ),
                    field("before", "query", "string", False, "", "Pagination cursor."),
                ],
            ),
            endpoint(
                id="gists-detail",
                method="GET",
                path="/gists/{gist_slug}",
                title="View a gist",
                summary="Render a gist with comments. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "gist_slug",
                        "path",
                        "string",
                        True,
                        "GIST_SLUG",
                        "Slug or UID of the gist.",
                    )
                ],
            ),
            endpoint(
                id="gists-create",
                method="POST",
                path="/gists/create",
                title="Create a gist",
                summary="Publish a code snippet. Redirects to the new gist.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "title",
                        "form",
                        "string",
                        True,
                        "Quick sort",
                        "Title, 1-200 characters.",
                    ),
                    field(
                        "source_code",
                        "form",
                        "textarea",
                        True,
                        "print('hello')",
                        "Source, 1-400000 characters.",
                    ),
                    field(
                        "language",
                        "form",
                        "enum",
                        False,
                        "python",
                        "Syntax language.",
                        GIST_LANGUAGES,
                    ),
                    field(
                        "description",
                        "form",
                        "string",
                        False,
                        "",
                        "Optional description.",
                    ),
                ],
            ),
            endpoint(
                id="gists-edit",
                method="POST",
                path="/gists/edit/{gist_slug}",
                title="Edit a gist",
                summary="Update a gist you own.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "gist_slug",
                        "path",
                        "string",
                        True,
                        "GIST_SLUG",
                        "Slug or UID of the gist.",
                    ),
                    field(
                        "title",
                        "form",
                        "string",
                        True,
                        "Quick sort",
                        "Title, 1-200 characters.",
                    ),
                    field(
                        "source_code",
                        "form",
                        "textarea",
                        True,
                        "print('hi')",
                        "Source, 1-400000 characters.",
                    ),
                    field(
                        "language",
                        "form",
                        "enum",
                        False,
                        "python",
                        "Syntax language.",
                        GIST_LANGUAGES,
                    ),
                    field(
                        "description",
                        "form",
                        "string",
                        False,
                        "",
                        "Optional description.",
                    ),
                ],
            ),
            endpoint(
                id="gists-delete",
                method="POST",
                path="/gists/delete/{gist_slug}",
                title="Delete a gist",
                summary="Delete a gist you own; administrators may delete any user's gist. Soft-deleted (hidden everywhere but restorable from admin trash).",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "gist_slug",
                        "path",
                        "string",
                        True,
                        "GIST_SLUG",
                        "Slug or UID of the gist.",
                    )
                ],
            ),
            endpoint(
                id="news-list",
                method="GET",
                path="/news",
                title="Browse news",
                summary="Curated developer news. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "before",
                        "query",
                        "string",
                        False,
                        "",
                        "Pagination cursor (synced_at).",
                    )
                ],
            ),
            endpoint(
                id="news-detail",
                method="GET",
                path="/news/{news_slug}",
                title="View a news article",
                summary="Render a news article with comments. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "news_slug",
                        "path",
                        "string",
                        True,
                        "NEWS_SLUG",
                        "Slug or UID of the article.",
                    )
                ],
            ),
        ],
    },
    {
        "slug": "profiles",
        "title": "Profiles & Social Graph",
        "intro": """
# Profiles & Social Graph

Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
[Search & Lookups](/docs/lookups.html); follows generate entries in [Notifications](/docs/notifications.html).

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="profile-detail",
                method="GET",
                path="/profile/{username}",
                title="View a profile",
                summary="Render a user profile. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "{{ username }}",
                        "Target username.",
                    ),
                    field(
                        "tab",
                        "query",
                        "enum",
                        False,
                        "posts",
                        "Profile tab.",
                        ["posts", "activity", "followers", "following", "media"],
                    ),
                ],
            ),
            endpoint(
                id="profile-update",
                method="POST",
                path="/profile/update",
                title="Update your profile",
                summary="Update your own bio and links.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "bio",
                        "form",
                        "textarea",
                        False,
                        "Building things.",
                        "Bio, up to 500 characters.",
                    ),
                    field(
                        "location",
                        "form",
                        "string",
                        False,
                        "Earth",
                        "Location, up to 200 characters.",
                    ),
                    field("git_link", "form", "string", False, "", "Git profile URL."),
                    field(
                        "website", "form", "string", False, "", "Personal website URL."
                    ),
                ],
            ),
            endpoint(
                id="profile-ai-correction",
                method="POST",
                path="/profile/{username}/ai-correction",
                title="Configure AI content correction",
                summary="Enable or disable automatic AI rewriting of your prose and set the correction instruction. Opt-in, default off. Admins may target any user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Profile owner. Must be yourself unless you are an admin.",
                    ),
                    field(
                        "enabled",
                        "form",
                        "boolean",
                        False,
                        "true",
                        "true to enable background AI correction, false to disable it.",
                    ),
                    field(
                        "sync",
                        "form",
                        "boolean",
                        False,
                        "false",
                        "true to apply the correction synchronously (the save waits), false for background.",
                    ),
                    field(
                        "prompt",
                        "form",
                        "textarea",
                        False,
                        "Leave literary as is, only do punctuation and casing",
                        "Correction instruction, up to 2000 characters.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/profile/bob_test",
                    "data": {
                        "url": "/profile/bob_test",
                        "enabled": True,
                        "sync": False,
                        "prompt": "Leave literary as is, only do punctuation and casing",
                    },
                },
            ),
            endpoint(
                id="profile-ai-modifier",
                method="POST",
                path="/profile/{username}/ai-modifier",
                title="Configure the AI modifier",
                summary="Enable or disable the inline '@ai <instruction>' modifier on your prose and set its prompt. Enabled by default, applied synchronously by default. Admins may target any user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Profile owner. Must be yourself unless you are an admin.",
                    ),
                    field(
                        "enabled",
                        "form",
                        "boolean",
                        False,
                        "true",
                        "true to enable the AI modifier, false to disable it.",
                    ),
                    field(
                        "sync",
                        "form",
                        "boolean",
                        False,
                        "true",
                        "true to apply the modification synchronously (the save waits), false for background.",
                    ),
                    field(
                        "prompt",
                        "form",
                        "textarea",
                        False,
                        "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
                        "Modifier instruction, up to 2000 characters.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/profile/bob_test",
                    "data": {
                        "url": "/profile/bob_test",
                        "enabled": True,
                        "sync": True,
                        "prompt": "Execute what is behind `@ai` (the prompt) and replace that part including `@ai`",
                    },
                },
            ),
            endpoint(
                id="profile-regenerate-key",
                method="POST",
                path="/profile/regenerate-api-key",
                title="Regenerate your API key",
                summary="Issue a new API key and invalidate the current one.",
                auth="user",
                interactive=False,
                destructive=True,
                notes=[
                    "> Running this invalidates the key these documentation panels use. Do it from your "
                    "[profile page](/profile/{{ username }}) instead, then reload these docs.",
                ],
                sample_response={"api_key": "NEW_UUID"},
            ),
            endpoint(
                id="profile-customization-global",
                method="POST",
                path="/profile/{username}/customization/global",
                title="Toggle site-wide customizations",
                summary="Show or suppress your site-wide custom CSS and JS without deleting it. Admins may target any user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Profile owner. Must be yourself unless you are an admin.",
                    ),
                    field(
                        "value",
                        "form",
                        "boolean",
                        False,
                        "0",
                        "1 to show your site-wide customizations, 0 to suppress them.",
                    ),
                ],
            ),
            endpoint(
                id="profile-customization-pagetype",
                method="POST",
                path="/profile/{username}/customization/pagetype",
                title="Toggle per-page customizations",
                summary="Show or suppress your per-page custom CSS and JS without deleting it. Admins may target any user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Profile owner. Must be yourself unless you are an admin.",
                    ),
                    field(
                        "value",
                        "form",
                        "boolean",
                        False,
                        "0",
                        "1 to show your per-page customizations, 0 to suppress them.",
                    ),
                ],
            ),
            endpoint(
                id="profile-notification-toggle",
                method="POST",
                path="/profile/{username}/notifications",
                title="Toggle a notification preference",
                summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Profile owner. Must be yourself unless you are an admin.",
                    ),
                    field(
                        "notification_type",
                        "form",
                        "string",
                        True,
                        "vote",
                        "One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
                    ),
                    field(
                        "channel",
                        "form",
                        "string",
                        True,
                        "push",
                        "Either in_app or push.",
                    ),
                    field(
                        "value",
                        "form",
                        "boolean",
                        False,
                        "1",
                        "1 to deliver this notification on this channel, 0 to suppress it.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/profile/{{ username }}?tab=notifications",
                    "data": {
                        "notification_type": "vote",
                        "channel": "push",
                        "value": False,
                    },
                },
            ),
            endpoint(
                id="profile-notification-reset",
                method="POST",
                path="/profile/{username}/notifications/reset",
                title="Reset notification preferences",
                summary="Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Profile owner. Must be yourself unless you are an admin.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/profile/{{ username }}?tab=notifications",
                },
            ),
            endpoint(
                id="media-delete",
                method="POST",
                path="/media/{uid}/delete",
                title="Delete media",
                summary="Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "uid",
                        "path",
                        "string",
                        True,
                        "",
                        "Attachment uid, taken from the Media tab response.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/profile/{{ username }}?tab=media",
                },
            ),
            endpoint(
                id="follow-user",
                method="POST",
                path="/follow/{username}",
                title="Follow a user",
                summary="Follow another user. Idempotent.",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Username to follow.",
                    )
                ],
            ),
            endpoint(
                id="unfollow-user",
                method="POST",
                path="/follow/unfollow/{username}",
                title="Unfollow a user",
                summary="Stop following a user.",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "bob_test",
                        "Username to unfollow.",
                    )
                ],
            ),
            endpoint(
                id="list-followers",
                method="GET",
                path="/profile/{username}/followers",
                title="List followers",
                summary="List the users who follow a profile, 25 per page. Returns JSON.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "{{ username }}",
                        "Target username.",
                    ),
                    field(
                        "page",
                        "query",
                        "integer",
                        False,
                        "1",
                        "Page number, 25 per page.",
                    ),
                ],
                sample_response={
                    "username": "{{ username }}",
                    "mode": "followers",
                    "count": 2,
                    "page": 1,
                    "total_pages": 1,
                    "followers": [
                        {
                            "uid": "UUID",
                            "username": "bob_test",
                            "bio": "Building things.",
                            "is_following": False,
                        },
                    ],
                },
            ),
            endpoint(
                id="list-following",
                method="GET",
                path="/profile/{username}/following",
                title="List following",
                summary="List the users a profile follows, 25 per page. Returns JSON.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "username",
                        "path",
                        "string",
                        True,
                        "{{ username }}",
                        "Target username.",
                    ),
                    field(
                        "page",
                        "query",
                        "integer",
                        False,
                        "1",
                        "Page number, 25 per page.",
                    ),
                ],
                sample_response={
                    "username": "{{ username }}",
                    "mode": "following",
                    "count": 1,
                    "page": 1,
                    "total_pages": 1,
                    "following": [
                        {
                            "uid": "UUID",
                            "username": "alice_test",
                            "bio": "",
                            "is_following": True,
                        },
                    ],
                },
            ),
            endpoint(
                id="leaderboard",
                method="GET",
                path="/leaderboard",
                title="View the leaderboard",
                summary="Top contributors by stars. Returns an HTML page.",
                auth="public",
                interactive=True,
            ),
            endpoint(
                id="avatar",
                method="GET",
                path="/avatar/{style}/{seed}",
                title="Generate an avatar",
                summary="Deterministic SVG avatar for a seed. Returns an image.",
                auth="public",
                interactive=False,
                params=[
                    field(
                        "style",
                        "path",
                        "enum",
                        True,
                        "multiavatar",
                        "Avatar style.",
                        ["multiavatar"],
                    ),
                    field(
                        "seed",
                        "path",
                        "string",
                        True,
                        "{{ username }}",
                        "Seed string, usually a username.",
                    ),
                    field("size", "query", "int", False, "128", "Pixel size."),
                ],
            ),
        ],
    },
    {
        "slug": "messaging",
        "title": "Messaging",
        "intro": """
# Messaging

Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
recipients with [Search & Lookups](/docs/lookups.html) and attach files via [Uploads](/docs/uploads.html).

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="messages-inbox",
                method="GET",
                path="/messages",
                title="Open the inbox",
                summary="Render conversations. Returns an HTML page.",
                auth="user",
                interactive=True,
                params=[
                    field(
                        "with_uid",
                        "query",
                        "string",
                        False,
                        "",
                        "Open a specific conversation by user UID.",
                    ),
                    field(
                        "search",
                        "query",
                        "string",
                        False,
                        "",
                        "Jump to a conversation by username.",
                    ),
                ],
            ),
            endpoint(
                id="messages-send",
                method="POST",
                path="/messages/send",
                title="Send a message",
                summary="Send a direct message to a user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "Hello there.",
                        "Body, 1-2000 characters.",
                    ),
                    field(
                        "receiver_uid",
                        "form",
                        "string",
                        True,
                        "RECEIVER_UID",
                        "Recipient user UID.",
                    ),
                ],
            ),
        ],
    },
    {
        "slug": "notifications",
        "title": "Notifications",
        "intro": """
# Notifications

Read your notification feed and mark items read. The unread counts endpoint backs the badges
in the navigation bar. Deliver these to the browser with [Web Push](/docs/push.html).

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="notifications-list",
                method="GET",
                path="/notifications",
                title="View notifications",
                summary="Render your notifications. Returns an HTML page.",
                auth="user",
                interactive=True,
                params=[
                    field("before", "query", "string", False, "", "Pagination cursor.")
                ],
            ),
            endpoint(
                id="notifications-counts",
                method="GET",
                path="/notifications/counts",
                title="Unread counts",
                summary="Unread notification and message counts.",
                auth="public",
                notes=[
                    'Guests receive `{ "notifications": 0, "messages": 0 }` instead of an error, so the navigation badge works before login.'
                ],
                sample_response={"notifications": 2, "messages": 1},
            ),
            endpoint(
                id="notifications-open",
                method="GET",
                path="/notifications/open/{notification_uid}",
                title="Open a notification",
                summary="Mark a notification read and redirect to its target.",
                auth="user",
                interactive=False,
                params=[
                    field(
                        "notification_uid",
                        "path",
                        "string",
                        True,
                        "NOTIFICATION_UID",
                        "UID of the notification.",
                    )
                ],
            ),
            endpoint(
                id="notifications-mark-read",
                method="POST",
                path="/notifications/mark-read/{notification_uid}",
                title="Mark one read",
                summary="Mark a single notification as read.",
                auth="user",
                params=[
                    field(
                        "notification_uid",
                        "path",
                        "string",
                        True,
                        "NOTIFICATION_UID",
                        "UID of the notification.",
                    )
                ],
            ),
            endpoint(
                id="notifications-mark-all-read",
                method="POST",
                path="/notifications/mark-all-read",
                title="Mark all read",
                summary="Mark every notification as read.",
                auth="user",
            ),
        ],
    },
    {
        "slug": "uploads",
        "title": "Uploads",
        "intro": """
# Uploads

Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
comment, project, gist, message, or issue - see
[Posts, Comments, Projects, Gists & News](/docs/content.html). Images and videos embed and
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="uploads-upload",
                method="POST",
                path="/uploads/upload",
                title="Upload a file",
                summary="Store a file and return its attachment record.",
                auth="user",
                encoding="multipart",
                params=[field("file", "form", "file", True, "", "The file to upload.")],
                notes=[
                    "Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.",
                    "Returns `201` on success, `413` if too large, `415` if the type is not allowed.",
                ],
                sample_response={
                    "uid": "ATTACHMENT_UID",
                    "filename": "clip.mp4",
                    "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4",
                    "size": 20480,
                    "is_image": False,
                    "is_video": True,
                    "mime_type": "video/mp4",
                },
            ),
            endpoint(
                id="uploads-upload-url",
                method="POST",
                path="/uploads/upload-url",
                title="Attach a file from a URL",
                summary="Download a public URL on the server and store it as an attachment.",
                auth="user",
                params=[
                    field(
                        "url",
                        "form",
                        "string",
                        True,
                        "https://example.com/photo.png",
                        "Public http(s) URL of the file to download and attach.",
                    ),
                    field(
                        "filename",
                        "form",
                        "string",
                        False,
                        "photo.png",
                        "Optional filename with an allowed extension, used when the URL has no clear name.",
                    ),
                ],
                notes=[
                    "The server fetches the URL (SSRF-guarded, size-capped) and stores the bytes through the same pipeline as a direct upload; the response is identical to Upload a file.",
                    "The file type is taken from the URL path or the response Content-Type. Returns `201` on success, `413` if too large, `415` if the type cannot be resolved to an allowed type, `400` for an unreachable or private address.",
                ],
                sample_response={
                    "uid": "ATTACHMENT_UID",
                    "filename": "photo.png",
                    "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
                    "size": 20480,
                    "is_image": True,
                    "is_video": False,
                    "mime_type": "image/png",
                },
            ),
            endpoint(
                id="uploads-delete",
                method="DELETE",
                path="/uploads/delete/{attachment_uid}",
                title="Delete an attachment",
                summary="Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).",
                auth="user",
                destructive=True,
                params=[
                    field(
                        "attachment_uid",
                        "path",
                        "string",
                        True,
                        "ATTACHMENT_UID",
                        "UID of the attachment.",
                    )
                ],
                sample_response={"status": "deleted"},
            ),
        ],
    },
    {
        "slug": "project-files",
        "title": "Project Filesystem",
        "intro": """
# Project Filesystem

Each project carries a full virtual filesystem - directories and files - so a project can hold a
complete software project. Reading is public (anyone can browse a project's tree); creating,
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
binary files are uploaded and served from `/static/uploads/project_files/...`.

Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
segments are rejected.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, status codes); see [Authentication](/docs/authentication.html) for the four ways to
sign requests.
""",
        "endpoints": [
            endpoint(
                id="project-files-list",
                method="GET",
                path="/projects/{project_slug}/files",
                title="List a project's files",
                summary="Return the flat list of files and directories in a project.",
                auth="public",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    )
                ],
                sample_response={
                    "project": {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG"},
                    "files": [
                        {
                            "path": "src/main.py",
                            "name": "main.py",
                            "type": "file",
                            "is_binary": False,
                            "size": 42,
                        }
                    ],
                    "is_owner": False,
                },
            ),
            endpoint(
                id="project-files-raw",
                method="GET",
                path="/projects/{project_slug}/files/raw",
                title="Read a project file",
                summary="Return one file's metadata and (for text files) its content.",
                auth="public",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "query",
                        "string",
                        True,
                        "src/main.py",
                        "Relative file path inside the project.",
                    ),
                ],
                sample_response={
                    "path": "src/main.py",
                    "name": "main.py",
                    "type": "file",
                    "is_binary": False,
                    "mime_type": "text/plain",
                    "size": 42,
                    "url": None,
                    "content": "print('hello')\n",
                },
            ),
            endpoint(
                id="project-files-write",
                method="POST",
                path="/projects/{project_slug}/files/write",
                title="Write a text file",
                summary="Create or overwrite a text file; parent directories are created automatically.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "form",
                        "string",
                        True,
                        "src/main.py",
                        "Relative file path.",
                    ),
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "print('hello')",
                        "Full file content (max 400000 chars).",
                    ),
                ],
                notes=["Owner only; non-owners get `403`. Invalid paths return `400`."],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/main.py", "type": "file"},
                },
            ),
            endpoint(
                id="project-files-lines",
                method="GET",
                path="/projects/{project_slug}/files/lines",
                title="Read a line range",
                summary="Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.",
                auth="public",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "query",
                        "string",
                        True,
                        "src/main.py",
                        "Relative file path.",
                    ),
                    field(
                        "start",
                        "query",
                        "integer",
                        False,
                        "1",
                        "First line, 1-indexed (default 1).",
                    ),
                    field(
                        "end",
                        "query",
                        "integer",
                        False,
                        "50",
                        "Last line inclusive; omit or -1 for end of file.",
                    ),
                ],
                notes=[
                    "Text files only; binary, directory, or missing paths return `404`."
                ],
                sample_response={
                    "path": "src/main.py",
                    "start": 1,
                    "end": 2,
                    "total_lines": 2,
                    "lines": ["import os", "print(os.getcwd())"],
                    "content": "import os\nprint(os.getcwd())",
                },
            ),
            endpoint(
                id="project-files-replace-lines",
                method="POST",
                path="/projects/{project_slug}/files/replace-lines",
                title="Replace a line range",
                summary="Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "form",
                        "string",
                        True,
                        "src/main.py",
                        "Relative file path.",
                    ),
                    field(
                        "start",
                        "form",
                        "integer",
                        True,
                        "10",
                        "First line to replace (1-indexed).",
                    ),
                    field(
                        "end",
                        "form",
                        "integer",
                        True,
                        "12",
                        "Last line to replace (inclusive).",
                    ),
                    field(
                        "content",
                        "form",
                        "textarea",
                        False,
                        "new code",
                        "Replacement text (empty deletes the range).",
                    ),
                ],
                notes=[
                    "Owner only. The preferred way to edit a large file; avoids rewriting the whole file."
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/main.py", "type": "file"},
                },
            ),
            endpoint(
                id="project-files-insert-lines",
                method="POST",
                path="/projects/{project_slug}/files/insert-lines",
                title="Insert lines",
                summary="Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "form",
                        "string",
                        True,
                        "src/main.py",
                        "Relative file path.",
                    ),
                    field(
                        "at",
                        "form",
                        "integer",
                        True,
                        "1",
                        "Insert before this 1-indexed line.",
                    ),
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "# header",
                        "Text to insert.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/main.py", "type": "file"},
                },
            ),
            endpoint(
                id="project-files-delete-lines",
                method="POST",
                path="/projects/{project_slug}/files/delete-lines",
                title="Delete a line range",
                summary="Delete lines start..end (inclusive) from a text file.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "form",
                        "string",
                        True,
                        "src/main.py",
                        "Relative file path.",
                    ),
                    field(
                        "start",
                        "form",
                        "integer",
                        True,
                        "5",
                        "First line to delete (1-indexed).",
                    ),
                    field(
                        "end",
                        "form",
                        "integer",
                        True,
                        "7",
                        "Last line to delete (inclusive).",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/main.py", "type": "file"},
                },
            ),
            endpoint(
                id="project-files-append",
                method="POST",
                path="/projects/{project_slug}/files/append",
                title="Append to a file",
                summary="Append content as new lines at the end of a text file; grow a large file across calls without resending it.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path", "form", "string", True, "log.txt", "Relative file path."
                    ),
                    field(
                        "content",
                        "form",
                        "textarea",
                        True,
                        "next chunk",
                        "Text to append.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "log.txt", "type": "file"},
                },
            ),
            endpoint(
                id="project-files-upload",
                method="POST",
                path="/projects/{project_slug}/files/upload",
                title="Upload a file into a project",
                summary="Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.",
                auth="user",
                encoding="multipart",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field("file", "form", "file", True, "", "The file to upload."),
                    field(
                        "path",
                        "form",
                        "string",
                        False,
                        "assets",
                        "Target directory, empty for the root.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {
                        "path": "assets/logo.png",
                        "type": "file",
                        "is_binary": True,
                    },
                },
            ),
            endpoint(
                id="project-files-mkdir",
                method="POST",
                path="/projects/{project_slug}/files/mkdir",
                title="Create a directory",
                summary="Create a directory and any missing parents.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "form",
                        "string",
                        True,
                        "src/components",
                        "Relative directory path.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/components", "type": "dir"},
                },
            ),
            endpoint(
                id="project-files-move",
                method="POST",
                path="/projects/{project_slug}/files/move",
                title="Move or rename",
                summary="Move or rename a file or directory (and its descendants).",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "from_path",
                        "form",
                        "string",
                        True,
                        "src/old.py",
                        "Existing path.",
                    ),
                    field("to_path", "form", "string", True, "src/new.py", "New path."),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/new.py"},
                },
            ),
            endpoint(
                id="project-files-delete",
                method="POST",
                path="/projects/{project_slug}/files/delete",
                title="Delete a file or directory",
                summary="Delete a file, or a directory and everything under it. Project owner or an administrator; soft-deleted (restorable from admin trash). Blocked while the project is read-only.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "form",
                        "string",
                        True,
                        "src/old.py",
                        "Relative path to delete.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/projects/PROJECT_SLUG/files",
                    "data": {"path": "src/old.py"},
                },
            ),
            endpoint(
                id="project-files-zip",
                method="POST",
                path="/projects/{project_slug}/files/zip",
                title="Queue a zip of files",
                summary="Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.",
                auth="public",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "path",
                        "query",
                        "string",
                        False,
                        "src",
                        "Relative file or directory to archive; empty for the whole project.",
                    ),
                ],
                sample_response={
                    "uid": "ZIP_JOB_UID",
                    "status_url": "/zips/ZIP_JOB_UID",
                },
            ),
        ],
    },
    {
        "slug": "containers",
        "title": "Container Manager",
        "admin": True,
        "intro": """
# Container Manager

Build versioned Docker images for a project and run supervised container instances. Every endpoint is
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
Mutations flip desired state; a single reconciler converges containers to it.
""",
        "endpoints": [
            endpoint(
                id="containers-page",
                method="GET",
                path="/projects/{project_slug}/containers",
                title="Container manager page",
                summary="The admin per-project container manager UI (Dockerfiles, builds, instance creation).",
                auth="admin",
                interactive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    )
                ],
            ),
            endpoint(
                id="containers-admin-index",
                method="GET",
                path="/admin/containers",
                title="Admin containers list",
                summary="The admin Containers section: every instance across all projects, each linking to its detail page.",
                auth="admin",
                interactive=True,
            ),
            endpoint(
                id="containers-admin-data",
                method="GET",
                path="/admin/containers/data",
                title="Admin containers list data",
                summary="JSON of every instance across all projects (decorated with project title/slug) for polling.",
                auth="admin",
                sample_response={
                    "instances": [
                        {
                            "uid": "INSTANCE_UID",
                            "name": "staging",
                            "status": "running",
                            "project_slug": "PROJECT_SLUG",
                            "project_title": "My Project",
                            "ingress_slug": "my-service",
                            "restart_policy": "always",
                        }
                    ]
                },
            ),
            endpoint(
                id="containers-admin-instance",
                method="GET",
                path="/admin/containers/{uid}",
                title="Instance detail page",
                summary="The dedicated detail page for one instance (lifecycle, logs, metrics, terminal, schedules, ingress, sync).",
                auth="admin",
                interactive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                    )
                ],
            ),
            endpoint(
                id="containers-admin-edit-page",
                method="GET",
                path="/admin/containers/{uid}/edit",
                title="Edit instance page",
                summary="The edit page for one instance (run-as user, boot language/script/command, restart policy, start-on-boot, limits).",
                auth="admin",
                interactive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                    )
                ],
            ),
            endpoint(
                id="containers-create-instance",
                method="POST",
                path="/projects/{project_slug}/containers/instances",
                title="Create an instance",
                summary="Create and (by default) start an instance; it runs the shared ppy image with the project workspace mounted at /app.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field("name", "form", "string", True, "staging", "Instance name."),
                    field(
                        "boot_command",
                        "form",
                        "string",
                        False,
                        "python app.py",
                        "Optional boot command.",
                    ),
                    field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
                    field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
                    field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
                    field(
                        "env",
                        "form",
                        "textarea",
                        False,
                        "KEY=VALUE",
                        "Env vars, one KEY=VALUE per line.",
                    ),
                    field(
                        "ports",
                        "form",
                        "string",
                        False,
                        "80",
                        "Port maps. Bare container port auto-assigns a unique host port above 20000; host:container pins one.",
                    ),
                    field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
                    field(
                        "mem_limit", "form", "string", False, "512m", "Memory limit."
                    ),
                    field(
                        "restart_policy",
                        "form",
                        "enum",
                        False,
                        "never",
                        "Restart policy.",
                        ["never", "always", "on-failure", "unless-stopped"],
                    ),
                    field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
                    field(
                        "ingress_slug",
                        "form",
                        "string",
                        False,
                        "my-service",
                        "Publish at /p/<slug> (optional).",
                    ),
                    field(
                        "ingress_port",
                        "form",
                        "integer",
                        False,
                        "8899",
                        "Container port to publish (must be a mapped port).",
                    ),
                ],
            ),
            endpoint(
                id="containers-ingress",
                method="GET",
                path="/p/{slug}",
                title="Container ingress proxy",
                summary="Public reverse proxy (HTTP and WebSocket) to a running instance published via ingress_slug. The /p/<slug> prefix is stripped before forwarding.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "slug",
                        "path",
                        "string",
                        True,
                        "my-service",
                        "The instance's ingress_slug.",
                    )
                ],
            ),
            endpoint(
                id="containers-instance-action",
                method="POST",
                path="/projects/{project_slug}/containers/instances/{uid}/{action}",
                title="Instance lifecycle",
                summary="start, stop, restart, pause, or resume an instance (flips desired state).",
                auth="admin",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                    ),
                    field(
                        "action",
                        "path",
                        "enum",
                        True,
                        "start",
                        "Lifecycle action.",
                        ["start", "stop", "restart", "pause", "resume"],
                    ),
                ],
            ),
            endpoint(
                id="containers-instance-logs",
                method="GET",
                path="/projects/{project_slug}/containers/instances/{uid}/logs",
                title="Instance logs",
                summary="Recent docker logs of a running instance.",
                auth="admin",
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                    ),
                    field("tail", "query", "integer", False, "200", "Number of lines."),
                ],
                sample_response={"logs": "..."},
            ),
            endpoint(
                id="containers-instance-sync",
                method="POST",
                path="/projects/{project_slug}/containers/instances/{uid}/sync",
                title="Sync workspace",
                summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
                auth="admin",
                destructive=True,
                params=[
                    field(
                        "project_slug",
                        "path",
                        "string",
                        True,
                        "PROJECT_SLUG",
                        "Project slug or uid.",
                    ),
                    field(
                        "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                    ),
                ],
                sample_response={"exported": 3, "imported": 1},
            ),
        endpoint(
            id="containers-instance-delete",
            method="POST",
            path="/projects/{project_slug}/containers/instances/{uid}/delete",
            title="Delete instance",
            summary="Remove a container instance and mark its container for removal.",
            auth="admin",
            destructive=True,
            params=[
                field(
                    "project_slug",
                    "path",
                    "string",
                    True,
                    "PROJECT_SLUG",
                    "Project slug or uid.",
                ),
                field(
                    "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                ),
            ],
        ),
        endpoint(
            id="containers-instance-exec",
            method="POST",
            path="/projects/{project_slug}/containers/instances/{uid}/exec",
            title="Exec a command",
            summary="Run a one-shot command inside a running instance and return its output.",
            auth="admin",
            encoding="form",
            destructive=True,
            params=[
                field(
                    "project_slug",
                    "path",
                    "string",
                    True,
                    "PROJECT_SLUG",
                    "Project slug or uid.",
                ),
                field(
                    "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                ),
                field(
                    "command",
                    "form",
                    "string",
                    True,
                    "ls -la /app",
                    "Shell command to run (via /bin/sh -c).",
                ),
            ],
        ),
        endpoint(
            id="containers-instance-data",
            method="GET",
            path="/projects/{project_slug}/containers/instances/{uid}",
            title="Instance detail data",
            summary="Return the full instance row plus runtime info as JSON.",
            auth="admin",
            params=[
                field(
                    "project_slug",
                    "path",
                    "string",
                    True,
                    "PROJECT_SLUG",
                    "Project slug or uid.",
                ),
                field(
                    "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                ),
            ],
            sample_response={"uid": "INSTANCE_UID", "name": "staging", "status": "running"},
        ),
        endpoint(
            id="containers-instance-metrics",
            method="GET",
            path="/projects/{project_slug}/containers/instances/{uid}/metrics",
            title="Instance metrics",
            summary="Return recent metrics ring-buffer and aggregated stats for a running instance.",
            auth="admin",
            params=[
                field(
                    "project_slug",
                    "path",
                    "string",
                    True,
                    "PROJECT_SLUG",
                    "Project slug or uid.",
                ),
                field(
                    "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                ),
            ],
            sample_response={"metrics": [], "stats": {}},
        ),
        endpoint(
            id="containers-instance-schedules",
            method="POST",
            path="/projects/{project_slug}/containers/instances/{uid}/schedules",
            title="Create a schedule",
            summary="Attach a cron, one-time, interval, or delay schedule to an instance.",
            auth="admin",
            encoding="form",
            destructive=True,
            params=[
                field(
                    "project_slug",
                    "path",
                    "string",
                    True,
                    "PROJECT_SLUG",
                    "Project slug or uid.",
                ),
                field(
                    "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                ),
                field(
                    "action",
                    "form",
                    "string",
                    True,
                    "start",
                    "Lifecycle action to run on schedule (start, stop, restart).",
                ),
                field(
                    "kind",
                    "form",
                    "string",
                    True,
                    "cron",
                    "Schedule kind: cron, once, interval, or delay.",
                ),
                field(
                    "cron",
                    "form",
                    "string",
                    False,
                    "0 * * * *",
                    "Cron expression (when kind is cron).",
                ),
                field(
                    "run_at",
                    "form",
                    "string",
                    False,
                    "2026-01-01T00:00:00",
                    "ISO timestamp for a one-time run (when kind is once).",
                ),
                field(
                    "delay_seconds",
                    "form",
                    "integer",
                    False,
                    "60",
                    "Seconds to wait before a single run (when kind is delay).",
                ),
                field(
                    "every_seconds",
                    "form",
                    "integer",
                    False,
                    "300",
                    "Interval in seconds between runs (when kind is interval).",
                ),
                field(
                    "max_runs",
                    "form",
                    "integer",
                    False,
                    "10",
                    "Optional cap on the number of runs.",
                ),
            ],
        ),
        endpoint(
            id="containers-instance-schedule-delete",
            method="POST",
            path="/projects/{project_slug}/containers/instances/{uid}/schedules/{sid}/delete",
            title="Delete a schedule",
            summary="Remove a schedule from an instance.",
            auth="admin",
            destructive=True,
            params=[
                field(
                    "project_slug",
                    "path",
                    "string",
                    True,
                    "PROJECT_SLUG",
                    "Project slug or uid.",
                ),
                field(
                    "uid", "path", "string", True, "INSTANCE_UID", "Instance uid."
                ),
                field(
                    "sid", "path", "string", True, "SCHEDULE_UID", "Schedule uid."
                ),
            ],
        ),
        endpoint(
            id="containers-admin-create",
            method="POST",
            path="/admin/containers/create",
            title="Admin create instance",
            summary="Create an instance from the admin Containers page: project search-select, run-as user, boot language/script, restart policy, start-on-boot, plus the usual options.",
            auth="admin",
            encoding="form",
            destructive=True,
            params=[
                field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
                field("name", "form", "string", True, "staging", "Instance name."),
                field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
                field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
                field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
                field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
                field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
                field("start_on_boot", "form", "boolean", False, "false", "Force running whenever the container service starts."),
                field("env", "form", "textarea", False, "KEY=VALUE", "Env vars, one KEY=VALUE per line."),
                field("ports", "form", "string", False, "80", "Port maps; bare container port auto-assigns a host port above 20000."),
                field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
                field("mem_limit", "form", "string", False, "512m", "Memory limit."),
                field("ingress_slug", "form", "string", False, "my-service", "Publish at /p/<slug> (optional)."),
                field("ingress_port", "form", "integer", False, "8899", "Container port to publish."),
            ],
        ),
        endpoint(
            id="containers-admin-edit",
            method="POST",
            path="/admin/containers/{uid}/edit",
            title="Admin edit instance",
            summary="Update an instance's run-as user, boot language/script/command, restart policy, start-on-boot flag, and resource limits.",
            auth="admin",
            encoding="form",
            destructive=True,
            params=[
                field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
                field("run_as_uid", "form", "string", False, "USER_UID", "Run-as user uid (identity + API key only)."),
                field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
                field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code."),
                field("boot_command", "form", "string", False, "python app.py", "Fallback boot command."),
                field("restart_policy", "form", "enum", False, "never", "Restart policy.", ["never", "always", "on-failure", "unless-stopped"]),
                field("start_on_boot", "form", "boolean", False, "false", "Force running on container-service boot."),
                field("cpu_limit", "form", "string", False, "1.5", "CPU limit."),
                field("mem_limit", "form", "string", False, "512m", "Memory limit."),
            ],
        ),
        endpoint(
            id="containers-admin-action",
            method="POST",
            path="/admin/containers/{uid}/{action}",
            title="Admin instance lifecycle",
            summary="start, stop, restart, pause, or resume an instance from the admin Containers page (flips desired state).",
            auth="admin",
            destructive=True,
            params=[
                field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
                field("action", "path", "enum", True, "start", "Lifecycle action.", ["start", "stop", "restart", "pause", "resume"]),
            ],
        ),
        endpoint(
            id="containers-admin-sync",
            method="POST",
            path="/admin/containers/{uid}/sync",
            title="Admin bidirectional sync",
            summary="Run a one-shot bidirectional newer-wins sync between the project files and the container workspace.",
            auth="admin",
            destructive=True,
            params=[
                field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
            ],
            sample_response={"exported": 3, "imported": 1},
        ),
        endpoint(
            id="containers-admin-delete",
            method="POST",
            path="/admin/containers/{uid}/delete",
            title="Admin delete instance",
            summary="Soft-delete an instance and mark its container for removal.",
            auth="admin",
            destructive=True,
            params=[
                field("uid", "path", "string", True, "INSTANCE_UID", "Instance uid."),
            ],
        ),
        endpoint(
            id="containers-admin-project-search",
            method="GET",
            path="/admin/containers/projects/search",
            title="Admin project search",
            summary="Search projects by title for the admin create form (returns uid, slug, title).",
            auth="admin",
            params=[
                field("q", "query", "string", False, "api", "Title fragment."),
            ],
            sample_response={"results": [{"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "title": "My Project"}]},
        ),
        endpoint(
            id="containers-admin-user-search",
            method="GET",
            path="/admin/containers/users/search",
            title="Admin run-as user search",
            summary="Search users by username for the run-as-user select (returns uid, username).",
            auth="admin",
            params=[
                field("q", "query", "string", False, "alice", "Username fragment."),
            ],
            sample_response={"results": [{"uid": "USER_UID", "username": "alice"}]},
        ),
        ],
    },
    {
        "slug": "tools",
        "title": "Tools (SEO Diagnostics)",
        "intro": """
# Tools: SEO Diagnostics

A public auditor that crawls a URL or sitemap with a headless browser and runs a broad battery of
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks. It runs
as a background job; poll the status URL (or watch the websocket) until `status` is `done`, then read
the full report.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
status and report.
""",
        "endpoints": [
            endpoint(
                id="tools-seo-run",
                method="POST",
                path="/tools/seo/run",
                title="Queue an SEO audit",
                summary="Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.",
                auth="public",
                encoding="form",
                params=[
                    field("url", "form", "string", True, "https://example.com", "Page URL or sitemap.xml URL to audit."),
                    field("mode", "form", "enum", False, "url", "'url' (single page) or 'sitemap' (crawl).", ["url", "sitemap"]),
                    field("max_pages", "form", "integer", False, "10", "Max pages to crawl in sitemap mode (1-50)."),
                ],
                sample_response={
                    "uid": "SEO_JOB_UID",
                    "status_url": "/tools/seo/SEO_JOB_UID",
                    "ws_url": "/tools/seo/SEO_JOB_UID/ws",
                },
            ),
            endpoint(
                id="tools-seo-status",
                method="GET",
                path="/tools/seo/{uid}",
                title="SEO audit status",
                summary="Poll an SEO audit. Once done, score, grade and report_url are populated.",
                auth="public",
                params=[
                    field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid returned when the audit was queued."),
                ],
                sample_response={
                    "uid": "SEO_JOB_UID",
                    "kind": "seo",
                    "status": "done",
                    "target": "https://example.com",
                    "mode": "url",
                    "ws_url": "/tools/seo/SEO_JOB_UID/ws",
                    "report_url": "/tools/seo/SEO_JOB_UID/report",
                    "score": 82,
                    "grade": "B",
                    "page_count": 1,
                    "error": None,
                    "created_at": "2026-06-14T10:00:00+00:00",
                    "completed_at": "2026-06-14T10:00:18+00:00",
                },
            ),
            endpoint(
                id="tools-seo-report",
                method="GET",
                path="/tools/seo/{uid}/report",
                title="SEO audit report",
                summary="Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.",
                auth="public",
                params=[
                    field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
                ],
                sample_response={
                    "uid": "SEO_JOB_UID",
                    "status": "done",
                    "target": "https://example.com",
                    "score": 82,
                    "grade": "B",
                    "page_count": 1,
                    "counts": {"pass": 40, "warn": 8, "fail": 3, "info": 5, "skip": 0},
                    "categories": {"crawlability": {"score": 90, "pass": 9, "warn": 1, "fail": 0}},
                    "pages": [{"url": "https://example.com", "status": 200, "score": 82, "grade": "B"}],
                    "checks": [
                        {
                            "id": "meta.title_present",
                            "category": "meta",
                            "title": "Title tag",
                            "status": "pass",
                            "severity": "high",
                            "value": "Example Domain",
                            "recommendation": "",
                            "url": "https://example.com",
                        }
                    ],
                    "site": {"robots": {"status": 200}, "sitemap": {"status": 200, "url_count": 12}},
                    "generated_at": "2026-06-14T10:00:18+00:00",
                },
            ),
            endpoint(
                id="tools-seo-screenshot",
                method="GET",
                path="/tools/seo/{uid}/screenshot/{index}",
                title="SEO audit page screenshot",
                summary="Stream the rendered screenshot (image/png) captured for the audited page at the given zero-based index.",
                auth="public",
                interactive=True,
                params=[
                    field("uid", "path", "string", True, "SEO_JOB_UID", "SEO job uid of a finished audit."),
                    field("index", "path", "integer", True, "0", "Zero-based index of the audited page."),
                ],
            ),
            endpoint(
                id="tools-deepsearch-run",
                method="POST",
                path="/tools/deepsearch/run",
                title="Queue a DeepSearch research job",
                summary="Start a multi-agent deep web research job. Returns the job uid plus status and websocket URLs.",
                auth="public",
                encoding="form",
                params=[
                    field("query", "form", "string", True, "history of the transistor", "The research question to investigate."),
                    field("depth", "form", "integer", False, "2", "Research depth (1-4)."),
                    field("max_pages", "form", "integer", False, "12", "Maximum sources to crawl (1-30)."),
                ],
                sample_response={
                    "uid": "DEEPSEARCH_JOB_UID",
                    "status_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID",
                    "ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
                },
            ),
            endpoint(
                id="tools-deepsearch-status",
                method="GET",
                path="/tools/deepsearch/{uid}",
                title="DeepSearch status",
                summary="Poll a DeepSearch job. Once done, score, confidence and session_url are populated.",
                auth="public",
                params=[
                    field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid returned when the run was queued."),
                ],
                sample_response={
                    "uid": "DEEPSEARCH_JOB_UID",
                    "kind": "deepsearch",
                    "status": "done",
                    "query": "history of the transistor",
                    "depth": 2,
                    "max_pages": 12,
                    "ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
                    "chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
                    "session_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/session",
                    "score": 78,
                    "confidence": 0.72,
                    "source_diversity": 0.64,
                    "page_count": 11,
                    "chunk_count": 240,
                    "error": None,
                    "created_at": "2026-06-14T10:00:00+00:00",
                    "completed_at": "2026-06-14T10:01:40+00:00",
                },
            ),
            endpoint(
                id="tools-deepsearch-session",
                method="GET",
                path="/tools/deepsearch/{uid}/session",
                title="DeepSearch report",
                summary="Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.",
                auth="public",
                params=[
                    field("uid", "path", "string", True, "DEEPSEARCH_JOB_UID", "DeepSearch job uid of a finished run."),
                ],
                sample_response={
                    "uid": "DEEPSEARCH_JOB_UID",
                    "status": "done",
                    "query": "history of the transistor",
                    "score": 78,
                    "confidence": 0.72,
                    "source_diversity": 0.64,
                    "page_count": 11,
                    "chunk_count": 240,
                    "summary": "The transistor was invented at Bell Labs in 1947...",
                    "findings": [
                        {"title": "Invention", "detail": "...", "confidence": 0.8, "citations": [1]}
                    ],
                    "gaps": ["Limited coverage of later MOSFET developments."],
                    "sources": [{"url": "https://example.com", "title": "Example", "source": "httpx"}],
                    "chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
                    "export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
                    "export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
                    "export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
                },
            ),
        ],
    },
    {
        "slug": "push",
        "title": "Web Push",
        "intro": """
# Web Push

Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.

There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
to a subscription once its push endpoint reports it as gone. These mirror the in-app
[Notifications](/docs/notifications.html) feed.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="push-key",
                method="GET",
                path="/push.json",
                title="Get the public key",
                summary="Return the VAPID public key for subscribing.",
                auth="public",
                sample_response={"publicKey": "BASE64_VAPID_KEY"},
            ),
            endpoint(
                id="push-register",
                method="POST",
                path="/push.json",
                title="Register a subscription",
                summary="Register a browser push subscription. Sends a welcome notification.",
                auth="user",
                encoding="json",
                interactive=False,
                params=[
                    field(
                        "endpoint",
                        "json",
                        "string",
                        True,
                        "https://fcm.googleapis.com/...",
                        "Subscription endpoint URL.",
                    ),
                    field(
                        "keys",
                        "json",
                        "string",
                        True,
                        '{"p256dh":"...","auth":"..."}',
                        "Subscription keys object.",
                    ),
                ],
                notes=[
                    'The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.'
                ],
                sample_response={"registered": True},
            ),
        ],
    },
    {
        "slug": "issues",
        "title": "Issue Reports",
        "intro": """
# Issue Reports

The issue tracker is a full integration with a Gitea repository. The listing and detail views read
issues straight from Gitea with their live status, and a report you file is first rewritten by the
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
reporter is notified when a developer replies or the status changes, and a comment posted here is
pushed to Gitea and attributed to your account.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="issues-list",
                method="GET",
                path="/issues",
                title="List issue tickets",
                summary="Render the issue board from Gitea, paginated and filterable by state.",
                auth="public",
                interactive=True,
                params=[
                    field(
                        "state",
                        "query",
                        "string",
                        False,
                        "open",
                        "Filter: open (default), closed, or all.",
                    ),
                    field("page", "query", "integer", False, "1", "1-based page."),
                ],
            ),
            endpoint(
                id="issues-create",
                method="POST",
                path="/issues/create",
                title="Report an issue",
                summary="Enqueue an issue report. It is enhanced by AI and filed on the tracker.",
                auth="user",
                encoding="form",
                ajax=True,
                destructive=True,
                params=[
                    field(
                        "title",
                        "form",
                        "string",
                        True,
                        "Login button misaligned",
                        "Title, 1-200 characters.",
                    ),
                    field(
                        "description",
                        "form",
                        "textarea",
                        True,
                        "Steps to reproduce...",
                        "Description, 1-5000 characters.",
                    ),
                ],
                notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the issue number."],
                sample_response={
                    "uid": "JOB_UID",
                    "status_url": "/issues/jobs/JOB_UID",
                },
            ),
            endpoint(
                id="issues-job",
                method="GET",
                path="/issues/jobs/{uid}",
                title="Issue filing job status",
                summary="Poll the filing job; the result carries the new issue number and url.",
                auth="public",
                ajax=True,
                params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
                sample_response={
                    "uid": "JOB_UID",
                    "kind": "issue_create",
                    "status": "done",
                    "number": 42,
                    "issue_url": "/issues/42",
                    "enhanced": True,
                    "error": None,
                    "created_at": "2026-06-12T09:00:00+00:00",
                    "completed_at": "2026-06-12T09:00:03+00:00",
                },
            ),
            endpoint(
                id="issues-planning-queue",
                method="POST",
                path="/issues/planning",
                title="Generate a tickets planning report",
                summary="Enqueue a grouped, ordered markdown planning report of all open tickets. Admin only.",
                auth="admin",
                encoding="form",
                ajax=True,
                params=[],
                notes=["Returns a job uid and status_url. Poll the status_url until status is done to read the markdown and download it."],
                sample_response={
                    "uid": "PLANNING_JOB_UID",
                    "status_url": "/issues/planning/PLANNING_JOB_UID",
                },
            ),
            endpoint(
                id="issues-planning-status",
                method="GET",
                path="/issues/planning/{uid}",
                title="Planning report job status",
                summary="Poll the planning job; the result carries the rendered markdown and the download URL. Admin only.",
                auth="admin",
                ajax=True,
                params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
                sample_response={
                    "uid": "PLANNING_JOB_UID",
                    "kind": "planning",
                    "status": "done",
                    "download_url": "/issues/planning/PLANNING_JOB_UID/download",
                    "markdown": "# Open Tickets Planning\n\n...",
                    "ai_used": True,
                    "issue_count": 12,
                    "bytes_out": 4096,
                    "error": None,
                    "created_at": "2026-06-15T09:00:00+00:00",
                    "completed_at": "2026-06-15T09:00:05+00:00",
                },
            ),
            endpoint(
                id="issues-planning-download",
                method="GET",
                path="/issues/planning/{uid}/download",
                title="Download the planning report",
                summary="Download the generated planning report as a markdown file. Admin only.",
                auth="admin",
                interactive=True,
                params=[field("uid", "path", "string", True, "PLANNING_JOB_UID", "Planning job uid.")],
            ),
            endpoint(
                id="issues-detail",
                method="GET",
                path="/issues/{number}",
                title="View an issue ticket",
                summary="Render a Gitea issue and its comments. Returns an HTML page.",
                auth="public",
                interactive=True,
                params=[
                    field("number", "path", "integer", True, "12", "Issue number.")
                ],
            ),
            endpoint(
                id="issues-comment",
                method="POST",
                path="/issues/{number}/comment",
                title="Comment on an issue",
                summary="Post a comment to the Gitea issue, attributed to the current user.",
                auth="user",
                encoding="form",
                destructive=True,
                params=[
                    field("number", "path", "integer", True, "12", "Issue number."),
                    field(
                        "body",
                        "form",
                        "textarea",
                        True,
                        "I can reproduce this on mobile.",
                        "Comment, 1-5000 characters.",
                    ),
                ],
            ),
            endpoint(
                id="issues-status",
                method="POST",
                path="/issues/{number}/status",
                title="Change an issue status",
                summary="Open or close the Gitea issue. Admin only.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=[
                    field("number", "path", "integer", True, "12", "Issue number."),
                    field(
                        "status",
                        "form",
                        "string",
                        True,
                        "closed",
                        "New status: open or closed.",
                    ),
                ],
            ),
        ],
    },
    {
        "slug": "gateway",
        "title": "OpenAI Gateway",
        "admin": True,
        "intro": """
# OpenAI Gateway

An OpenAI-compatible proxy mounted at `/openai/v1`. It forwards requests to a configured
upstream using the gateway's own credentials, so no DevPlace key is required - but an
administrator must enable the `openai` service first. Point any OpenAI-compatible client at
`{{ base }}/openai/v1`.

This gateway is the **single point of truth for AI** on the platform. Every other DevPlace
service (news, bots, Devii) calls it by default instead of an external provider, sends the
generic model name `molodetz`, and authenticates with an internal key that is auto-generated
on first boot. The real provider URLs, models, and keys (DeepSeek, OpenRouter) live only here,
so an operator switches providers or backends in one place. `DEEPSEEK_API_KEY` and
`OPENROUTER_API_KEY` are migrated into the editable settings on boot and the value in use is
shown. Because `Force model` is on by default, the upstream always receives the configured
model regardless of what a client (or `molodetz`) requests.

The gateway also performs **vision** augmentation: when a request includes an image, the gateway
describes the image with a configured vision model and rewrites it to text, so a vision-less
upstream still works. The vision model, URL, and key are configured alongside the other gateway
settings.

The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.

## Per-call cost and usage headers

Every gateway response - chat, embeddings, and passthrough, on both success and error - carries
`X-Gateway-*` response headers describing that single call, so a client can read its own token usage
and dollar cost directly from the response with no extra request:

| Header | Meaning |
|--------|---------|
| `X-Gateway-Model` | Upstream model actually used for the call |
| `X-Gateway-Backend` | Backend that served it: `chat`, `embed`, or passthrough |
| `X-Gateway-Prompt-Tokens` | Input (prompt) tokens |
| `X-Gateway-Completion-Tokens` | Output (completion) tokens |
| `X-Gateway-Total-Tokens` | Total tokens (prompt + completion) |
| `X-Gateway-Cache-Hit-Tokens` | Prompt tokens served from the upstream prompt cache |
| `X-Gateway-Cache-Miss-Tokens` | Prompt tokens not served from cache |
| `X-Gateway-Reasoning-Tokens` | Reasoning tokens, when the model reports them |
| `X-Gateway-Cost-USD` | Total cost of the call in US dollars |
| `X-Gateway-Input-Cost-USD` | Input portion of the cost in US dollars |
| `X-Gateway-Output-Cost-USD` | Output portion of the cost in US dollars |
| `X-Gateway-Cost-Native` | `1` if the dollar cost is the upstream's own reported cost, `0` if computed from the configured per-million pricing |
| `X-Gateway-Tokens-Per-Second` | Output tokens per second for the call |
| `X-Gateway-Upstream-Latency-Ms` | Upstream round-trip latency in milliseconds |
| `X-Gateway-Total-Latency-Ms` | Full end-to-end gateway time for the call in milliseconds |
| `X-Gateway-Gateway-Overhead-Ms` | Gateway processing time minus the upstream and queue wait, in milliseconds |
| `X-Gateway-Queue-Wait-Ms` | Time spent waiting on the concurrency semaphore before dispatch, in milliseconds |
| `X-Gateway-Connect-Ms` | Upstream connection establishment time in milliseconds |
| `X-Gateway-Context-Window` | The model's context window in tokens, when known |
| `X-Gateway-Context-Utilization` | Total tokens as a fraction of the context window, when known |

Dollar costs use the upstream's native `cost` field when it returns one
(`X-Gateway-Cost-Native: 1`); otherwise they are computed from the per-million input and output
prices configured on the `openai` service. The one denied path that makes no upstream call
(embeddings disabled) returns no usage headers.

Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).

The gateway is exempt from rate limiting, but every other endpoint follows the shared
[Conventions & Errors](/docs/conventions.html); see [Authentication](/docs/authentication.html)
for signing DevPlace's own requests.
""",
        "endpoints": [
            endpoint(
                id="gateway-chat",
                method="POST",
                path="/openai/v1/chat/completions",
                title="Chat completions",
                summary="OpenAI-compatible chat completion. Supports streaming.",
                auth="user",
                encoding="json",
                params=[
                    field(
                        "model",
                        "json",
                        "string",
                        False,
                        "gpt-4o-mini",
                        "Model id; the gateway may override it.",
                    ),
                    field(
                        "messages",
                        "json",
                        "string",
                        True,
                        '[{"role":"user","content":"Hello"}]',
                        "Chat messages array.",
                    ),
                    field(
                        "stream",
                        "json",
                        "string",
                        False,
                        "false",
                        "Set true for a streamed SSE response.",
                    ),
                ],
                notes=[
                    "Returns `503` when the gateway service is not running.",
                    "Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above), including the streamed SSE response.",
                ],
            ),
            endpoint(
                id="gateway-embeddings",
                method="POST",
                path="/openai/v1/embeddings",
                title="Embeddings",
                summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
                auth="user",
                encoding="json",
                params=[
                    field(
                        "model",
                        "json",
                        "string",
                        False,
                        "molodetz~embed",
                        "Embedding model id; the gateway maps molodetz~embed to the configured model.",
                    ),
                    field(
                        "input",
                        "json",
                        "string",
                        True,
                        '"some text to embed"',
                        "String or array of strings to embed.",
                    ),
                    field(
                        "dimensions",
                        "json",
                        "string",
                        False,
                        "4096",
                        "Optional output vector size (Matryoshka, 32-4096).",
                    ),
                ],
                notes=[
                    "Returns `503` when the gateway service is not running or embeddings are disabled.",
                    "Every response carries the `X-Gateway-*` token and dollar-cost headers (see Per-call cost and usage headers above).",
                ],
            ),
            endpoint(
                id="gateway-passthrough",
                method="POST",
                path="/openai/v1/{path}",
                title="Passthrough",
                summary="Any other /v1 path is forwarded to the upstream as-is.",
                auth="user",
                interactive=False,
                params=[
                    field(
                        "path",
                        "path",
                        "string",
                        True,
                        "models",
                        "Upstream API path after /v1/.",
                    )
                ],
            ),
        ],
    },
    {
        "slug": "services",
        "title": "Background Services",
        "admin": True,
        "dynamic": True,
        "intro": "",
        "endpoints": [],
    },
    {
        "slug": "admin",
        "title": "Admin API",
        "admin": True,
        "intro": """
# Admin API

Site administration endpoints for users, news curation, and settings. Every call requires an
**admin** account. Background service management lives on the
[Background Services](/docs/services.html) page.

Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
four ways to sign requests.
""",
        "endpoints": [
            endpoint(
                id="admin-users",
                method="GET",
                path="/admin/users",
                title="List users",
                summary="Paginated user management page. Returns HTML.",
                auth="admin",
                interactive=True,
                params=[field("page", "query", "int", False, "1", "Page number.")],
            ),
            endpoint(
                id="admin-media",
                method="GET",
                path="/admin/media",
                title="Media trash",
                summary=(
                    "Moderation view of deleted media. When a member or admin deletes an "
                    "attachment it is soft-deleted: hidden from the gallery and from its parent "
                    "object, but the row and file are kept and the relation to the parent is "
                    "preserved. This page lists every soft-deleted attachment, newest first, with "
                    "its uploader, so an admin can restore or permanently purge it. Returns HTML."
                ),
                auth="admin",
                interactive=True,
                params=[field("page", "query", "int", False, "1", "Page number.")],
            ),
            endpoint(
                id="media-restore",
                method="POST",
                path="/media/{uid}/restore",
                title="Restore media",
                summary=(
                    "Restore a soft-deleted attachment. Because the parent relation is never "
                    "cleared, it reappears in the owner's Media tab and on its original post, "
                    "project, or gist immediately."
                ),
                auth="admin",
                destructive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "", "Soft-deleted attachment uid."
                    ),
                ],
                sample_response={"ok": True, "redirect": "/admin/media"},
            ),
            endpoint(
                id="admin-media-purge",
                method="POST",
                path="/admin/media/{uid}/purge",
                title="Purge media",
                summary=(
                    "Permanently delete a soft-deleted attachment: removes the database row and "
                    "deletes the file from disk. This cannot be undone and is the only way media "
                    "is hard-deleted from the UI."
                ),
                auth="admin",
                destructive=True,
                params=[
                    field("uid", "path", "string", True, "", "Attachment uid to purge."),
                ],
                sample_response={"ok": True, "redirect": "/admin/media"},
            ),
            endpoint(
                id="admin-audit-log",
                method="GET",
                path="/admin/audit-log",
                title="Audit log",
                summary=(
                    "Paginated, filterable audit event list (newest first, 25 per page). "
                    "Negotiates HTML or JSON. Every state-changing action on the platform "
                    "is recorded here with actor, origin, target, change, and result."
                ),
                auth="admin",
                interactive=True,
                params=[
                    field("page", "query", "int", False, "1", "Page number."),
                    field("event_key", "query", "string", False, "post.create", "Filter by exact event key."),
                    field("category", "query", "string", False, "admin", "Filter by category (auth, content, admin, container, ...)."),
                    field("actor_role", "query", "string", False, "admin", "Filter by actor role at action time."),
                    field("actor_uid", "query", "string", False, "", "Filter by acting user uid."),
                    field("origin", "query", "string", False, "web", "Filter by origin (web, api, devii, cli, service, scheduler)."),
                    field("result", "query", "string", False, "denied", "Filter by result (success, failure, denied)."),
                    field("q", "query", "string", False, "", "Free-text search over summary, event key, and target."),
                    field("date_from", "query", "string", False, "", "ISO date lower bound (inclusive)."),
                    field("date_to", "query", "string", False, "", "ISO date upper bound (inclusive)."),
                ],
                sample_response={
                    "entries": [
                        {
                            "uid": "AUDIT_UID",
                            "created_at": "2026-06-11T20:00:00+00:00",
                            "event_key": "admin.setting.update",
                            "category": "admin",
                            "actor_username": "ADMIN",
                            "actor_role": "admin",
                            "origin": "web",
                            "via_agent": 0,
                            "target_type": "setting",
                            "old_value": "0",
                            "new_value": "1",
                            "result": "success",
                        }
                    ],
                    "pagination": {"page": 1, "total": 1, "total_pages": 1},
                    "filters": {},
                    "options": {"category": ["admin", "auth", "content"]},
                },
            ),
            endpoint(
                id="admin-audit-event",
                method="GET",
                path="/admin/audit-log/{uid}",
                title="Audit event",
                summary="A single audit event with its full row and every related-object link.",
                auth="admin",
                interactive=True,
                params=[field("uid", "path", "string", True, "AUDIT_UID", "Audit event uid.")],
                sample_response={
                    "event": {"uid": "AUDIT_UID", "event_key": "container.instance.start", "result": "success"},
                    "links": [
                        {"relation": "actor", "object_type": "user", "object_uid": "USER_UID"},
                        {"relation": "instance", "object_type": "instance", "object_uid": "INSTANCE_UID"},
                    ],
                },
            ),
            endpoint(
                id="admin-analytics",
                method="GET",
                path="/admin/analytics",
                title="Site analytics",
                summary=(
                    "One-call aggregate analytics, returned as JSON: total members, active users "
                    "in the last 24h/7d/30d, signed_in_now, new signups (24h/7d/30d), content "
                    "totals (posts, comments, gists, projects, news), and top authors. Use this "
                    "instead of paging the user list to count or measure activity."
                ),
                auth="admin",
                interactive=True,
                params=[
                    field(
                        "top_n",
                        "query",
                        "int",
                        False,
                        "10",
                        "How many top authors to include (1-50).",
                    )
                ],
                notes=[
                    "`signed_in_now` counts members holding an unexpired session (logged in within the session lifetime, default 7-30 days), not a real-time online/presence count; the response carries a `signed_in_definition` explaining this. `active_*` means created content in the window (`active_definition`).",
                    "This is the endpoint the Devii assistant calls as `site_analytics`; see [Devii internals](/docs/devii-internals.html).",
                ],
            ),
            endpoint(
                id="admin-ai-usage",
                method="GET",
                path="/admin/ai-usage/data",
                title="AI gateway usage analytics",
                summary=(
                    "One-call AI gateway metrics, returned as JSON for a bounded time window: request "
                    "volume and throughput, token usage with averages and percentiles (p50/p90/p95/p99), "
                    "latency (upstream, gateway overhead, queue wait, connection establishment), error "
                    "rates by category, cost in USD (per model, per caller, input vs output, projected "
                    "monthly burn, caching savings), caller behavior, and an hourly breakdown. Cost is "
                    "taken from the upstream native cost when present (OpenRouter) and computed from the "
                    "configured per-million pricing otherwise (DeepSeek)."
                ),
                auth="admin",
                interactive=True,
                params=[
                    field(
                        "hours",
                        "query",
                        "int",
                        False,
                        "48",
                        "Lookback window in hours (1-168).",
                    ),
                    field(
                        "top_n",
                        "query",
                        "int",
                        False,
                        "10",
                        "How many rows in each top-N breakdown.",
                    ),
                ],
                notes=[
                    "TTFT and inter-token latency are not reported: the gateway forwards non-streaming to the upstream."
                ],
            ),
            endpoint(
                id="admin-user-ai-usage",
                method="GET",
                path="/admin/users/{uid}/ai-usage",
                title="Per-user AI usage",
                summary=(
                    "One user's AI gateway usage over the last 24 hours, returned as JSON: request "
                    "volume, success and error rates, token totals, cost (window, per hour, per request, "
                    "and a 30-day projection from the full 24h spend), average latency and throughput, a "
                    "per-model breakdown, and an hourly cost series. Because Devii operates a signed-in "
                    "user's account with that user's own API key, this is the user's complete gateway "
                    "spend, whether driven through Devii or direct API calls. Shown admin-only on the "
                    "user's profile page."
                ),
                auth="admin",
                interactive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "USER_UID", "Target user UID."
                    ),
                    field(
                        "hours",
                        "query",
                        "int",
                        False,
                        "24",
                        "Lookback window in hours (1-168).",
                    ),
                ],
                sample_response={
                    "owner_id": "USER_UID",
                    "window_hours": 24,
                    "requests": 42,
                    "success": 41,
                    "failed": 1,
                    "success_pct": 97.6,
                    "error_pct": 2.4,
                    "tokens": {"prompt": 120000, "completion": 38000, "total": 158000},
                    "cost": {
                        "window_usd": 0.214,
                        "per_hour_usd": 0.0089,
                        "per_request_usd": 0.0051,
                        "projected_30d_usd": 6.42,
                    },
                    "latency": {"avg_ms": 1830.0, "avg_tps": 41.2},
                    "by_model": [
                        {
                            "key": "molodetz",
                            "requests": 42,
                            "total_tokens": 158000,
                            "cost_usd": 0.214,
                        }
                    ],
                    "hourly": [
                        {
                            "hour": "2026-06-08T16",
                            "requests": 6,
                            "cost_usd": 0.031,
                            "total_tokens": 22000,
                        }
                    ],
                },
            ),
            endpoint(
                id="admin-user-role",
                method="POST",
                path="/admin/users/{uid}/role",
                title="Set a user role",
                summary="Promote or demote a user. You cannot change your own role.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "USER_UID", "Target user UID."
                    ),
                    field(
                        "role",
                        "form",
                        "enum",
                        True,
                        "member",
                        "New role.",
                        ["member", "admin"],
                    ),
                ],
            ),
            endpoint(
                id="admin-user-password",
                method="POST",
                path="/admin/users/{uid}/password",
                title="Reset a user password",
                summary="Set a new password for a user.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "USER_UID", "Target user UID."
                    ),
                    field(
                        "password",
                        "form",
                        "string",
                        True,
                        "newpassword",
                        "New password, 6+ characters.",
                    ),
                ],
            ),
            endpoint(
                id="admin-user-toggle",
                method="POST",
                path="/admin/users/{uid}/toggle",
                title="Enable or disable a user",
                summary="Toggle a user's active state. You cannot disable yourself.",
                auth="admin",
                destructive=True,
                params=[
                    field("uid", "path", "string", True, "USER_UID", "Target user UID.")
                ],
            ),
            endpoint(
                id="admin-news-list",
                method="GET",
                path="/admin/news",
                title="List news articles",
                summary="Paginated news management page. Returns HTML.",
                auth="admin",
                interactive=True,
                params=[field("page", "query", "int", False, "1", "Page number.")],
            ),
            endpoint(
                id="admin-news-toggle",
                method="POST",
                path="/admin/news/{uid}/toggle",
                title="Toggle featured",
                summary="Toggle an article's featured flag.",
                auth="admin",
                destructive=True,
                params=[
                    field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
                ],
            ),
            endpoint(
                id="admin-news-publish",
                method="POST",
                path="/admin/news/{uid}/publish",
                title="Toggle published",
                summary="Switch an article between draft and published.",
                auth="admin",
                destructive=True,
                params=[
                    field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
                ],
            ),
            endpoint(
                id="admin-news-landing",
                method="POST",
                path="/admin/news/{uid}/landing",
                title="Toggle landing",
                summary="Toggle whether an article shows on the landing page.",
                auth="admin",
                destructive=True,
                params=[
                    field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
                ],
            ),
            endpoint(
                id="admin-news-delete",
                method="POST",
                path="/admin/news/{uid}/delete",
                title="Delete a news article",
                summary="Delete an article and its images.",
                auth="admin",
                destructive=True,
                params=[
                    field("uid", "path", "string", True, "NEWS_UID", "Article UID.")
                ],
            ),
            endpoint(
                id="admin-settings-get",
                method="GET",
                path="/admin/settings",
                title="Read site settings",
                summary="Return the current site and operational settings. Negotiates HTML or JSON.",
                auth="admin",
                interactive=True,
            ),
            endpoint(
                id="admin-settings",
                method="POST",
                path="/admin/settings",
                title="Save site settings",
                summary="Update operational and site settings. Empty fields are skipped.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "site_name", "form", "string", False, "DevPlace", "Site name."
                    ),
                    field(
                        "rate_limit_per_minute",
                        "form",
                        "string",
                        False,
                        "60",
                        "Requests per window.",
                    ),
                    field(
                        "registration_open",
                        "form",
                        "enum",
                        False,
                        "1",
                        "Allow signups.",
                        ["1", "0"],
                    ),
                    field(
                        "maintenance_mode",
                        "form",
                        "enum",
                        False,
                        "0",
                        "Maintenance gate.",
                        ["1", "0"],
                    ),
                ],
                notes=[
                    "Accepts every field on the admin settings form; only non-empty values are written."
                ],
            ),
            endpoint(
                id="admin-notification-default",
                method="POST",
                path="/admin/notifications",
                title="Set a notification default",
                summary="Set the platform-wide default for one notification type on one channel. Applies to users who have not customized that notification; explicit user choices are unaffected.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=[
                    field(
                        "notification_type",
                        "form",
                        "string",
                        True,
                        "vote",
                        "One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
                    ),
                    field(
                        "channel",
                        "form",
                        "string",
                        True,
                        "push",
                        "Either in_app or push.",
                    ),
                    field(
                        "value",
                        "form",
                        "boolean",
                        False,
                        "1",
                        "1 to enable this notification by default, 0 to disable.",
                    ),
                ],
                sample_response={
                    "ok": True,
                    "redirect": "/admin/notifications",
                    "data": {
                        "notification_type": "vote",
                        "channel": "push",
                        "value": False,
                    },
                },
            ),
            endpoint(
                id="admin-user-reset-ai-quota",
                method="POST",
                path="/admin/users/{uid}/reset-ai-quota",
                title="Reset user AI quota",
                summary="Delete a specific user's AI gateway ledger rows, resetting their quota.",
                auth="admin",
                destructive=True,
                params=[
                    field(
                        "uid", "path", "string", True, "USER_UID", "Target user UID."
                    ),
                ],
            ),
            endpoint(
                id="admin-ai-quota-reset-guests",
                method="POST",
                path="/admin/ai-quota/reset-guests",
                title="Reset guest AI quotas",
                summary="Reset AI quota for all anonymous guest users.",
                auth="admin",
                destructive=True,
            ),
            endpoint(
                id="admin-ai-quota-reset-all",
                method="POST",
                path="/admin/ai-quota/reset-all",
                title="Reset all AI quotas",
                summary="Reset AI quota for every user (members and guests).",
                auth="admin",
                destructive=True,
            ),
            endpoint(
                id="admin-bots-monitor",
                method="GET",
                path="/admin/bots",
                title="Bot monitor page",
                summary="Live low-quality screenshot grid of every running bot persona, one frame per bot.",
                auth="admin",
                interactive=True,
            ),
            endpoint(
                id="admin-bots-data",
                method="GET",
                path="/admin/bots/data",
                title="Bot monitor data",
                summary="JSON of every running bot slot with its label, persona, current action/status, and latest frame URL for polling.",
                auth="admin",
                sample_response={
                    "enabled": True,
                    "service_status": "running",
                    "frames": [
                        {
                            "slot": 0,
                            "username": "bytewren",
                            "persona": "grumpy_senior",
                            "action": "POST: rant [3]",
                            "status": "page load",
                            "url": "https://example.com/feed",
                            "label": "bytewren",
                            "captured_at": 1718366400,
                            "age_seconds": 2,
                            "has_image": True,
                            "active": True,
                            "frame_url": "/admin/bots/0/frame.jpg?t=1718366400",
                        }
                    ],
                },
            ),
            endpoint(
                id="admin-bots-frame",
                method="GET",
                path="/admin/bots/{slot}/frame.jpg",
                title="Bot frame image",
                summary="The latest low-quality JPEG screenshot for one bot slot (no-store).",
                auth="admin",
                encoding="none",
                interactive=False,
                params=[
                    field(
                        "slot",
                        "path",
                        "integer",
                        True,
                        "0",
                        "Bot fleet slot index.",
                    )
                ],
            ),
            endpoint(
                id="admin-backups",
                method="GET",
                path="/admin/backups",
                title="Backups dashboard",
                summary=(
                    "Storage usage, every backup archive, and every backup schedule. Returns HTML "
                    "(or JSON with Accept: application/json)."
                ),
                auth="admin",
                interactive=True,
            ),
            endpoint(
                id="admin-backups-data",
                method="GET",
                path="/admin/backups/data",
                title="Backups data",
                summary=(
                    "JSON dashboard payload: per-path storage usage, total data and backup size, "
                    "disk usage, the backup list, and the schedule list."
                ),
                auth="admin",
            ),
            endpoint(
                id="admin-backups-run",
                method="POST",
                path="/admin/backups/run",
                title="Create backup",
                summary=(
                    "Enqueue an async backup job for a target (database, uploads, keys, full). "
                    "Returns the job uid and a status_url."
                ),
                auth="admin",
                params=[
                    field(
                        "target",
                        "form",
                        "string",
                        True,
                        "full",
                        "One of database, uploads, keys, full.",
                    )
                ],
                sample_response={
                    "ok": True,
                    "uid": "JOB_UID",
                    "backup_uid": "BACKUP_UID",
                    "status_url": "/admin/backups/jobs/JOB_UID",
                },
            ),
            endpoint(
                id="admin-backups-job",
                method="GET",
                path="/admin/backups/jobs/{uid}",
                title="Backup job status",
                summary="Status of one backup job (pending, running, done, failed) with archive stats.",
                auth="admin",
                params=[field("uid", "path", "string", True, "", "Backup job uid.")],
                sample_response={
                    "uid": "JOB_UID",
                    "kind": "backup",
                    "status": "done",
                    "target": "full",
                    "backup_uid": "BACKUP_UID",
                    "download_url": "/admin/backups/BACKUP_UID/download",
                    "bytes_out": 10485760,
                    "file_count": 1240,
                    "sha256": "…",
                },
            ),
            endpoint(
                id="admin-backups-download",
                method="GET",
                path="/admin/backups/{uid}/download",
                title="Download backup",
                summary="Stream a completed backup archive as a tar.gz file.",
                auth="admin",
                encoding="none",
                params=[field("uid", "path", "string", True, "", "Backup uid.")],
            ),
            endpoint(
                id="admin-backups-delete",
                method="POST",
                path="/admin/backups/{uid}/delete",
                title="Delete backup",
                summary="Permanently delete a backup archive and reclaim its disk space.",
                auth="admin",
                destructive=True,
                params=[field("uid", "path", "string", True, "", "Backup uid.")],
                sample_response={"ok": True, "redirect": "/admin/backups"},
            ),
            endpoint(
                id="admin-backups-schedule-create",
                method="POST",
                path="/admin/backups/schedules/create",
                title="Create backup schedule",
                summary=(
                    "Create a recurring backup. kind=interval uses every_seconds; kind=cron uses a "
                    "5-field cron expression. keep_last rotates older backups of the schedule."
                ),
                auth="admin",
                params=[
                    field("name", "form", "string", True, "Nightly full", "Schedule name."),
                    field("target", "form", "string", True, "full", "Backup target."),
                    field("kind", "form", "string", True, "interval", "interval or cron."),
                    field("every_seconds", "form", "int", False, "86400", "Seconds between runs (kind=interval)."),
                    field("cron", "form", "string", False, "0 3 * * *", "Cron expression (kind=cron)."),
                    field("keep_last", "form", "int", False, "7", "Keep only the newest N (0 = all)."),
                ],
                sample_response={"ok": True, "redirect": "/admin/backups"},
            ),
            endpoint(
                id="admin-backups-schedule-edit",
                method="POST",
                path="/admin/backups/schedules/{uid}/edit",
                title="Edit backup schedule",
                summary="Update a backup schedule's target, trigger, and retention.",
                auth="admin",
                params=[field("uid", "path", "string", True, "", "Schedule uid.")],
                sample_response={"ok": True, "redirect": "/admin/backups"},
            ),
            endpoint(
                id="admin-backups-schedule-toggle",
                method="POST",
                path="/admin/backups/schedules/{uid}/toggle",
                title="Toggle backup schedule",
                summary="Enable or disable a backup schedule.",
                auth="admin",
                params=[field("uid", "path", "string", True, "", "Schedule uid.")],
                sample_response={"ok": True, "redirect": "/admin/backups"},
            ),
            endpoint(
                id="admin-backups-schedule-run",
                method="POST",
                path="/admin/backups/schedules/{uid}/run",
                title="Run backup schedule now",
                summary="Immediately enqueue a backup for a schedule without waiting for its next run.",
                auth="admin",
                params=[field("uid", "path", "string", True, "", "Schedule uid.")],
                sample_response={"ok": True, "redirect": "/admin/backups"},
            ),
            endpoint(
                id="admin-backups-schedule-delete",
                method="POST",
                path="/admin/backups/schedules/{uid}/delete",
                title="Delete backup schedule",
                summary="Delete a backup schedule. Existing archives are kept.",
                auth="admin",
                destructive=True,
                params=[field("uid", "path", "string", True, "", "Schedule uid.")],
                sample_response={"ok": True, "redirect": "/admin/backups"},
            ),
        ],
    },
]

_PAGE_RESPONSES = {
    "auth-signup": schemas.AuthPageOut,
    "auth-login": schemas.AuthPageOut,
    "auth-forgot-password": schemas.AuthPageOut,
    "auth-reset-password": schemas.AuthPageOut,
    "home": schemas.LandingOut,
    "feed-list": schemas.FeedOut,
    "posts-detail": schemas.PostDetailOut,
    "projects-list": schemas.ProjectsOut,
    "projects-detail": schemas.ProjectDetailOut,
    "gists-list": schemas.GistsOut,
    "gists-detail": schemas.GistDetailOut,
    "news-list": schemas.NewsListOut,
    "news-detail": schemas.NewsDetailOut,
    "profile-detail": schemas.ProfileOut,
    "leaderboard": schemas.LeaderboardOut,
    "messages-inbox": schemas.MessagesOut,
    "notifications-list": schemas.NotificationsOut,
    "issues-list": schemas.IssuesOut,
    "issues-detail": schemas.IssueDetailOut,
    "bookmarks-saved": schemas.SavedOut,
    "admin-users": schemas.AdminUsersOut,
    "admin-news-list": schemas.AdminNewsOut,
    "admin-settings-get": schemas.AdminSettingsOut,
    "admin-audit-log": schemas.AuditLogOut,
    "admin-audit-event": schemas.AuditEventOut,
    "admin-bots-monitor": schemas.AdminBotsOut,
    "containers-admin-edit-page": schemas.AdminContainerEditOut,
}

_ACTION_RESPONSES = {
    "auth-signup-post": ("/feed", {"username": "alice"}),
    "auth-login-post": ("/feed", None),
    "auth-forgot-password-post": ("/auth/forgot-password?sent=1", None),
    "auth-reset-password-post": ("/auth/login", None),
    "auth-logout": ("/", None),
    "posts-create": (
        "/posts/POST_SLUG",
        {"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
    ),
    "posts-edit": (
        "/posts/POST_SLUG",
        {"uid": "POST_UID", "slug": "POST_SLUG", "url": "/posts/POST_SLUG"},
    ),
    "posts-delete": ("/feed", None),
    "comments-create": (
        "/posts/POST_SLUG#comment-COMMENT_UID",
        {"uid": "COMMENT_UID", "url": "/posts/POST_SLUG#comment-COMMENT_UID"},
    ),
    "comments-delete": (
        "/posts/POST_SLUG",
        {"deleted_uid": "COMMENT_UID", "target_type": "post", "target_uid": "TARGET_UID"},
    ),
    "projects-create": (
        "/projects/PROJECT_SLUG",
        {"uid": "PROJECT_UID", "slug": "PROJECT_SLUG", "url": "/projects/PROJECT_SLUG"},
    ),
    "projects-delete": ("/projects", None),
    "gists-create": (
        "/gists/GIST_SLUG",
        {"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
    ),
    "gists-edit": (
        "/gists/GIST_SLUG",
        {"uid": "GIST_UID", "slug": "GIST_SLUG", "url": "/gists/GIST_SLUG"},
    ),
    "gists-delete": ("/gists", None),
    "profile-update": ("/profile/YOUR_USERNAME", None),
    "profile-customization-global": (
        "/profile/YOUR_USERNAME",
        {"url": "/profile/YOUR_USERNAME", "cust_disable_global": True},
    ),
    "profile-customization-pagetype": (
        "/profile/YOUR_USERNAME",
        {"url": "/profile/YOUR_USERNAME", "cust_disable_pagetype": True},
    ),
    "follow-user": ("/profile/bob_test", None),
    "unfollow-user": ("/profile/bob_test", None),
    "messages-send": ("/messages?with_uid=RECEIVER_UID", {"uid": "MESSAGE_UID"}),
    "notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
    "notifications-mark-read": ("/notifications", None),
    "notifications-mark-all-read": ("/notifications", None),
    "issues-comment": ("/issues/12", {"comment_id": 1}),
    "issues-status": ("/issues/12", {"state": "closed"}),
    "admin-user-role": ("/admin/users", None),
    "admin-user-password": ("/admin/users", None),
    "admin-user-toggle": ("/admin/users", None),
    "admin-news-toggle": ("/admin/news", None),
    "admin-news-publish": ("/admin/news", None),
    "admin-news-landing": ("/admin/news", None),
    "admin-news-delete": ("/admin/news", None),
    "admin-settings": ("/admin/settings", None),
}


def _apply_negotiated_responses():
    for group in API_GROUPS:
        for ep in group.get("endpoints", []) or []:
            if ep.get("sample_response") is None:
                if ep["id"] in _PAGE_RESPONSES:
                    ep["sample_response"] = schema_example(_PAGE_RESPONSES[ep["id"]])
                elif ep["id"] in _ACTION_RESPONSES:
                    redirect, data = _ACTION_RESPONSES[ep["id"]]
                    ep["sample_response"] = action_example(redirect, data)
            ep["negotiation"] = _classify(ep)


_apply_negotiated_responses()

_GROUPS_BY_SLUG = {group["slug"]: group for group in API_GROUPS}


def get_group(slug):
    return _GROUPS_BY_SLUG.get(slug)


def api_doc_pages():
    return [
        {
            "slug": group["slug"],
            "title": group["title"],
            "kind": "api",
            "admin": group.get("admin", False),
            "dynamic": group.get("dynamic", False),
        }
        for group in API_GROUPS
    ]


def _field_to_param(spec):
    description = spec["label"]
    if spec.get("help"):
        description = f"{spec['label']} - {spec['help']}"
    value = "" if spec.get("secret") else str(spec.get("value", ""))
    if spec.get("secret"):
        description += " Leave blank to keep the current value."
    if spec["type"] == "bool":
        return field(
            spec["key"], "form", "enum", False, value or "0", description, ["1", "0"]
        )
    if spec["type"] == "select":
        options = [option["value"] for option in spec.get("options") or []]
        return field(
            spec["key"],
            "form",
            "enum",
            False,
            value or (options[0] if options else ""),
            description,
            options,
        )
    if spec["type"] in ("int", "float"):
        return field(spec["key"], "form", "int", False, value, description)
    return field(spec["key"], "form", "string", False, value, description)


def _service_section(service):
    enabled = "yes" if service.get("enabled") else "no"
    groups = ", ".join(group["name"] for group in service.get("field_groups", []))
    lines = [
        f"## {service.get('title') or service['name']}",
        "",
        service.get("description", ""),
        "",
        f"- Service name: `{service['name']}`",
        f"- Enabled: {enabled}",
        f"- Run interval: {service.get('interval_seconds', 0)}s",
    ]
    if groups:
        lines.append(f"- Configuration groups: {groups}")
    return "\n".join(lines)


def build_services_group(services, base):
    intro_parts = [
        "# Background Services",
        "",
        "DevPlace runs background services supervised by a single manager. Each service has its "
        "own enable flag, run interval, live status, metrics, and a rolling log buffer. Manage "
        "them from the admin panel at `/admin/services` or with the endpoints below.",
        "",
        "Control a service by its `name` (shown per service): start or stop it, trigger a single "
        "run, clear its logs, or save its configuration. `GET /admin/services/data` returns the "
        "live status, metrics, and log tail for every service.",
        "",
        "See the [Admin API](/docs/admin.html) for the rest of administration; the Devii assistant "
        "is itself a service, configured in [Configuration and CLI](/docs/devii-config.html).",
    ]
    config_endpoints = []
    for service in services:
        intro_parts.append("")
        intro_parts.append(_service_section(service))
        params = [
            _field_to_param(spec)
            for spec in service.get("fields", [])
            if not spec["key"].endswith("_enabled")
        ]
        config_endpoints.append(
            endpoint(
                id=f"services-config-{service['name']}",
                method="POST",
                path=f"/admin/services/{service['name']}/config",
                title=f"Configure {service.get('title') or service['name']}",
                summary=f"Save configuration for the {service['name']} service. Empty values keep the current setting.",
                auth="admin",
                encoding="form",
                destructive=True,
                params=params,
                notes=[
                    "Use start/stop to enable or disable the service; this saves the remaining settings."
                ],
            )
        )
    control_endpoints = [
        endpoint(
            id="services-data",
            method="GET",
            path="/admin/services/data",
            title="Service status",
            summary="Live status, metrics, and log tail for every background service.",
            auth="admin",
            sample_response={
                "services": [{"name": "news", "status": "running", "enabled": True}]
            },
        ),
        endpoint(
            id="services-detail-data",
            method="GET",
            path="/admin/services/{name}/data",
            title="One service status",
            summary="Live status, metrics, and log tail for a single background service.",
            auth="admin",
            params=[field("name", "path", "string", True, "news", "Service name.")],
            sample_response={
                "service": {"name": "news", "status": "running", "enabled": True}
            },
        ),
        *_service_control_endpoints(),
    ]
    endpoints = control_endpoints + config_endpoints
    for ep in endpoints:
        ep["negotiation"] = _classify(ep)
    return {
        "slug": "services",
        "title": "Background Services",
        "admin": True,
        "intro": "\n".join(intro_parts),
        "endpoints": endpoints,
    }


def _substitute(value, replacements):
    if isinstance(value, str):
        for token, replacement in replacements.items():
            value = value.replace(token, replacement)
        return value
    if isinstance(value, list):
        return [_substitute(item, replacements) for item in value]
    if isinstance(value, dict):
        return {key: _substitute(item, replacements) for key, item in value.items()}
    return value


def render_group(slug, base, username, api_key):
    group = get_group(slug)
    if not group:
        return None
    replacements = {
        "{{ base }}": base,
        "{{ username }}": username or "YOUR_USERNAME",
        "{{ api_key }}": api_key or "YOUR_API_KEY",
    }
    return _substitute(group, replacements)
