{
  "openapi": "3.0.3",
  "info": {
    "title": "MOJ API",
    "version": "v1",
    "description": "API REST do MOJ (Melhor Online Judge). Backend bash atrás de nginx+fcgiwrap. Auth via `Authorization: Bearer <token>`. Histórico e placar são TXT; o resto é JSON com envelope `{success:true,…}` ou `{success:false,error:{message,code}}`. Horários em EPOCH."
  },
  "servers": [
    {
      "url": "/api/v1",
      "description": "Instância atual"
    }
  ],
  "tags": [
    {
      "name": "auth"
    },
    {
      "name": "index"
    },
    {
      "name": "treino"
    },
    {
      "name": "submission"
    },
    {
      "name": "contest"
    },
    {
      "name": "admin"
    },
    {
      "name": "ops"
    },
    {
      "name": "treino-admin"
    },
    {
      "name": "problems",
      "description": "Gestão e autoria de problemas do treino. Escrita keyless (git escondido; commit autorado pelo login via git-broker.sh). source/create/edit cobrem o pacote canônico inteiro."
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    },
    "parameters": {
      "contest": {
        "name": "contest",
        "in": "query",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "id do contest (ex.: treino)"
      }
    },
    "schemas": {
      "ProblemExample": {
        "type": "object",
        "description": "Exemplo (sample) — sempre aparente no enunciado.",
        "properties": {
          "input": {
            "type": "string"
          },
          "output": {
            "type": "string"
          },
          "explanation": {
            "type": "string",
            "description": "Markdown opcional; aparece logo após o exemplo no enunciado (gravado em docs/sample-notes.json, na ordem)."
          }
        },
        "required": [
          "input",
          "output"
        ]
      },
      "ProblemTest": {
        "type": "object",
        "description": "Teste oculto.",
        "properties": {
          "name": {
            "type": "string"
          },
          "input": {
            "type": "string"
          },
          "output": {
            "type": "string"
          },
          "group": {
            "type": "string",
            "description": "Grupo de pontuação (subtask), opcional."
          }
        },
        "required": [
          "input",
          "output"
        ]
      },
      "ProblemSol": {
        "type": "object",
        "properties": {
          "filename": {
            "type": "string"
          },
          "code": {
            "type": "string"
          }
        },
        "required": [
          "filename",
          "code"
        ]
      },
      "ProblemSols": {
        "type": "object",
        "description": "Soluções por categoria (cada lista de {filename,code}). good define o tempo-limite; wrong deve falhar; slow deve estourar o tempo.",
        "properties": {
          "good": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemSol"
            }
          },
          "slow": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemSol"
            }
          },
          "wrong": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemSol"
            }
          },
          "pass": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemSol"
            }
          },
          "upcoming": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemSol"
            }
          }
        }
      },
      "ProblemScore": {
        "type": "object",
        "description": "Pontuação por grupos (subtasks). O valor do problema é a soma dos pesos (pode passar de 100).",
        "properties": {
          "enabled": {
            "type": "boolean"
          },
          "groups": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "name": {
                  "type": "string"
                },
                "weight": {
                  "type": "number"
                },
                "glob": {
                  "type": "string",
                  "description": "Padrão glob dos testes do grupo. Pode ser uma LISTA de padrões separados por \", \" (ex.: \"g2_*, g3_*\") — todos casam o mesmo peso."
                }
              }
            }
          }
        }
      },
      "ProblemFields": {
        "type": "object",
        "description": "Campos do pacote canônico — source/create/edit cobrem o todo. Em edit, só os campos presentes são gravados.",
        "properties": {
          "title": {
            "type": "string",
            "description": "Título — vem do CAMPO (não de '% Título' no texto); o renderizador injeta o <h1>."
          },
          "enunciado_md": {
            "type": "string"
          },
          "enunciado_format": {
            "type": "string",
            "enum": [
              "md",
              "org",
              "tex"
            ]
          },
          "author": {
            "type": "string"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "conf_text": {
            "type": "string",
            "description": "conf do pacote (TLMOD/ULIMITS/STOPWHEN/MEMLIMITMB/…)."
          },
          "public": {
            "type": "boolean"
          },
          "collections": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "languages": {
            "type": "array",
            "description": "Ids de linguagem de submissão permitidos deste problema ([]/ausente = todas as PADRÃO). Linguagens exóticas/opt-in (pddl/grepe/sas/l/lpp/downward, e ids custom futuros) só aparecem no dropdown quando DECLARADAS aqui. Em edit: ausente = não toca; [] = limpa.",
            "items": {
              "type": "string"
            }
          },
          "examples": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemExample"
            }
          },
          "tests": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ProblemTest"
            }
          },
          "sols": {
            "$ref": "#/components/schemas/ProblemSols"
          },
          "score": {
            "$ref": "#/components/schemas/ProblemScore"
          },
          "editorial_md": {
            "type": "string",
            "description": "Resolução em markdown (docs/solucao.md). SÓ p/ setter — não vai ao aluno; opcional para liberar."
          }
        }
      }
    }
  },
  "paths": {
    "/auth/login": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Login → token",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "username": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                },
                "required": [
                  "username",
                  "password"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{token, logged_in, username, name, contest}"
          },
          "401": {
            "description": "credenciais inválidas"
          }
        },
        "description": "Em contest (≠ treino), a resposta inclui server_utc, offline_pubkey_pem e beacon (kit da submissão offline do moj-comp)."
      }
    },
    "/auth/status": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Status da sessão",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "contest",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{logged_in, login, name, is_admin, is_judge, is_staff, is_cstaff, is_chief, has_photo} (.cstaff = chefe de sede, não herda is_staff; has_photo evita que o avatar peça uma foto inexistente)"
          }
        }
      }
    },
    "/auth/logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Logout (apaga o arquivo de sessão mesmo se ela já não vale)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{logged_out:true}"
          }
        }
      }
    },
    "/index/news": {
      "get": {
        "tags": [
          "index"
        ],
        "summary": "Notícias",
        "responses": {
          "200": {
            "description": "{news:[{id,title,date,summary,url}]}"
          }
        }
      }
    },
    "/index/contests": {
      "get": {
        "tags": [
          "index"
        ],
        "summary": "Contests (abertos/por vir/encerrados); problems_count é 0 em contest por vir (não revela a quantidade de problemas antes do início)",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{open,upcoming,closed:{items,page,pages}}"
          }
        }
      }
    },
    "/index/open_training": {
      "get": {
        "tags": [
          "index"
        ],
        "summary": "Destaques do treino (top10, recentes, semana)",
        "responses": {
          "200": {
            "description": "{top_users,recent_solved,most_solved_week}"
          }
        }
      }
    },
    "/treino/problems": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Lista de problemas",
        "responses": {
          "200": {
            "description": "[{id,title,tags,collections,solved_count,attempted_count,public_at?}] (collections = .moj-meta.json do pacote; um problema pode estar em várias; public_at = epoch da 1ª publicação, ausente quando desconhecido)"
          }
        }
      }
    },
    "/treino/trending": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Top-10 problemas por submissões nos últimos 7 dias (estado inicial do Treino Livre); anônimo, sem problemas privados, cache var/trending.json com piso 6h",
        "responses": {
          "200": {
            "description": "{success,window_days:7,generated_at,problems:[{id,title,count,url}]} ordenado por count desc"
          }
        }
      }
    },
    "/treino/problem": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Problema (enunciado b64, autor, time limits, tags, coleções)",
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{id,title,author,statement_html_b64,time_limits,tags,collections,languages} (author = arquivo 'author' do pacote, verbatim; vários juntados por ', '; vazio se ausente; collections = coleções do .moj-meta.json; languages = ids de linguagem de submissão permitidos, [] = todas as PADRÃO — o front filtra o dropdown; exóticas/opt-in (pddl/grepe/sas/l/lpp/downward) só aparecem quando o problema as declara)"
          },
          "404": {
            "description": "não encontrado"
          }
        }
      }
    },
    "/treino/solvetry": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Resolvidos/tentados de um usuário",
        "parameters": [
          {
            "name": "user",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{solved:[ids],attempted:[ids]}"
          }
        }
      }
    },
    "/treino/history": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Histórico do usuário no problema (TXT)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "TXT: tempo:user:probid:lang:veredito:epoch:subid — veredito SEMPRE canônico (Accepted/Wrong Answer/…; pendentes e strings desconhecidas intactos); o detalhe (testes/pontos/grupos) vem do /submission/summary",
            "content": {
              "text/plain": {}
            }
          }
        }
      }
    },
    "/treino/history-full": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Histórico completo do usuário (TXT)",
        "parameters": [
          {
            "name": "user",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "TXT (7 campos) — veredito SEMPRE canônico; visitante do perfil público vê só o rótulo (o resumo do /submission/summary é só do dono)",
            "content": {
              "text/plain": {}
            }
          }
        }
      }
    },
    "/treino/profile": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Perfil (próprio com Bearer; ?user=X = visão pública, respeita privacidade; ambos trazem created_at, epoch de criação da conta)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "user",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "ver perfil público de outro usuário"
          }
        ],
        "responses": {
          "200": {
            "description": "perfil próprio: dados + cota de username + telegram:{linked,username,linked_at,changes_used,changes_limit,changes_remaining,next_available} (vínculo do login, sem o telegram_id; changes_limit null = .admin, livre); ?user=X: visão pública"
          }
        }
      },
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Atualiza nome, universidade, editor favorito e/ou privacidade",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "university": {
                    "type": "string"
                  },
                  "favorite_editor": {
                    "type": "string"
                  },
                  "profile_public": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "perfil atualizado"
          }
        }
      }
    },
    "/treino/profile/photo": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Foto de perfil (png 100x100)",
        "parameters": [
          {
            "name": "user",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "image/png",
            "content": {
              "image/png": {}
            }
          },
          "404": {
            "description": "sem foto / privado"
          }
        }
      },
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Envia foto (redimensiona p/ 100x100)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "image_b64": {
                    "type": "string"
                  }
                },
                "required": [
                  "image_b64"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{updated:true}"
          }
        }
      }
    },
    "/treino/editors": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Ranking dos editores favoritos declarados",
        "responses": {
          "200": {
            "description": "{editors:[{editor,count}], total}"
          }
        }
      }
    },
    "/treino/achievements": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Registro de conquistas do perfil (custom do admin ou default embarcado; avaliação no cliente — docs/PERFIL.md)",
        "responses": {
          "200": {
            "description": "{custom, version, achievements:[{id,icon,pt,en,kind,params,enabled}]}"
          }
        }
      }
    },
    "/treino/admin/achievements": {
      "post": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Salva o registro de conquistas ({achievements:[…]} validado) ou restaura o padrão ({restore_default:true}); .admin",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{saved,count} | {restored:true}"
          },
          "400": {
            "description": "achievements_invalid (primeira mensagem de validação)"
          }
        }
      }
    },
    "/treino/problem-stats": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Estatísticas de um problema (cacheado)",
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{total_submissions,distinct_attempted,distinct_solved,acceptance_rate,verdicts,by_language:[{lang,submissions,accepted,solvers}],editors,solver_avatars,solvers_public_count, daily:{YYYY-MM-DD:n}, monthly:[{m,subs,ac}], dow_hour:[{dow,hour,n}], first_ac_epochs:[epoch], tries:[{bucket,n}], tries_median, time_to_solve:[{bucket,n}], t2s_median, facts:{first_sub_epoch,last_sub_epoch,peak_day:{date,n},first_solver:{epoch,login?,name?}}, difficulty_percentile:{harder_than_pct,cohort,success_rate}|null, runtimes:[{lang,t}]} — datas/horas em America/Sao_Paulo; first_solver só expõe login/nome se o perfil for público; percentil = taxa de sucesso por usuário vs. acervo público (≥5 tentantes, coorte ≥10); runtimes = teste mais lento de cada ACEITA (results/ da era atual)"
          }
        }
      }
    },
    "/treino/admin/activity-log": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Feed COMPLETO de atividade do treino (login/submit/verdict/read/admin; calib opt-in) com filtros e format=csv (range inteiro p/ análise)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "until",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "kinds",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "csv de tipos (login,submit,verdict,read,admin,calib); vazio = tudo menos calib"
          },
          {
            "name": "user",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "action",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "format",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "csv"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{count, events:[{time,who,kind,action,details,ip}]} ou text/csv (attachment)"
          },
          "403": {
            "description": "não é admin"
          }
        }
      }
    },
    "/treino/admin/managed-users": {
      "get": {
        "tags": ["treino-admin"],
        "summary": "Contas GERIDAS (menores, sem Telegram — docs/CONTAS-GERIDAS.md); .admin",
        "security": [{"bearerAuth": []}],
        "responses": {"200": {"description": "{users:[{login,fullname,by,note,birthdate,minor,expires_at,disabled,created_at}]}"}}
      }
    },
    "/treino/admin/managed-create": {
      "post": {
        "tags": ["treino-admin"],
        "summary": "Cria contas geridas {users:[{fullname,birthdate,login?,note?,expires_at?}]} (1..500); senhas devolvidas UMA vez; .admin",
        "security": [{"bearerAuth": []}],
        "responses": {"200": {"description": "{created:[{login,password,fullname,birthdate}], skipped:[{reason}]}"}}
      }
    },
    "/treino/admin/managed-reset": {
      "post": {
        "tags": ["treino-admin"],
        "summary": "Senha nova p/ conta gerida {login} (devolvida uma vez; derruba sessões); .admin",
        "security": [{"bearerAuth": []}],
        "responses": {"200": {"description": "{reset, login, password, sessions_removed}"}}
      }
    },
    "/treino/admin/managed-update": {
      "post": {
        "tags": ["treino-admin"],
        "summary": "Edita conta gerida {login, note?, birthdate?, expires_at?|null, disabled?}; disabled:false devolve senha nova; .admin",
        "security": [{"bearerAuth": []}],
        "responses": {"200": {"description": "{updated, login, password?}"}}
      }
    },
    "/treino/admin/managed-remove": {
      "post": {
        "tags": ["treino-admin"],
        "summary": "Remove conta gerida {login} (arquiva em .removed-users); .admin",
        "security": [{"bearerAuth": []}],
        "responses": {"200": {"description": "{removed, login}"}}
      }
    },
    "/treino/admin/sessions": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Sessões ativas (login, IP, User-Agent)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{count,sessions:[{login,name,ip,user_agent,login_at}]}"
          },
          "403": {
            "description": "não-admin"
          }
        }
      }
    },
    "/treino/admin/access-log": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Log de acessos (filtra por dia)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "day",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "YYYY-MM-DD"
          }
        ],
        "responses": {
          "200": {
            "description": "{day,entries:[{time,login,ip,user_agent}]}"
          }
        }
      }
    },
    "/treino/admin/queue": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Submissões pendentes por lista + spool + fila de calibração. details=1 lista CADA pendente (quem, o quê, desde quando, estado no pipeline, teto 100); sub=<c>:<login>:<id> devolve o dossiê de uma (history, mojlog, fonte)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "details",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "1 = inclui pending_details:[{contest,login,problem,lang,id,since,age_s,state,has_source}]"
          },
          {
            "name": "sub",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "<contest>:<login>:<id> — dossiê de UMA pendente: {history_line,state,has_source,source_bytes,mojlog,mojlog_bytes}"
          }
        ],
        "responses": {
          "200": {
            "description": "{total_pending,spool_queued,calib_pending,calib_inflight,calib_targeted,lists:[{contest,name,pending}],pending_details:[…]}. Inclui `routing` (shards do escritor): {shards, workers:[{shard, alive_age_s, in_submit, in_results, in_other}], orphans, queue_depth, assigned, delivered_5m}"
          }
        }
      },
      "post": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Agir sobre uma pendente (auditado queue-resolve): requeue re-enfileira (409 no_source sem fonte); resolve aplica Judge Error|Compilation Error via setverdict",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": { "type": "string", "enum": ["requeue", "resolve"] },
                  "contest": { "type": "string" },
                  "login": { "type": "string" },
                  "id": { "type": "string" },
                  "verdict": { "type": "string", "enum": ["Judge Error", "Compilation Error"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{requeued:true}|{resolved:true,verdict}"
          },
          "409": {
            "description": "no_source (requeue sem fonte arquivada)"
          }
        }
      }
    },
    "/treino/admin/judges": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Máquinas de juiz (modelo pull) + o que cada uma roda agora",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{online,busy,machines:[{host,online,busy,status,langs,cage_root,cache,tl,current,current_jobs,queued_calibrate,slots,partition,topology,config,report}]} — current_jobs = TODOS os jobs em execução (um por slot ocupado, {kind:submission|calibrate|index|draining|disabled|unknown_busy, problem_id, since:epoch-do-claim, ...}); current = o 1º (compat); status = auto-relato do agente (ok|draining|disabled, null=agente antigo); slots:{free,total}; cache = pacotes EM DISCO (não RAM); queued_calibrate = calibrações direcionadas na fila do host"
          }
        }
      }
    },
    "/treino/admin/stats": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Números p/ gráficos + contagem/entrada de problemas + por autor",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{users,active_sessions,problems:{total,public,private},by_author:[{author,owner,total,public,private}],problems_public_by_day:[{day,count}],logins_per_day,submissions_per_day} — contagens da plataforma (privados contados, NÃO listados); public_by_day = entrada de públicos (data aproximada)"
          }
        }
      }
    },
    "/treino/admin/response-stats": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Tempo de resposta + volume de submissões (média/dia + mapas de calor)",
        "description": "Cacheado (regenera quando history/results mudam). Tempo (espera/julgamento/fila) só de submissões com finalized_at; VOLUME (subs_per_day/subs_by_dow_hour) conta TODAS as linhas do history. EPOCH/UTC.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{coverage, overall, per_day, by_dow_hour, subs_per_day:[{day,count}], subs_by_dow_hour:[{dow,hour,n}]}"
          },
          "403": {
            "description": "não-admin (admin_required)"
          }
        }
      }
    },
    "/treino/admin/calib-activity": {
      "get": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Volume de calibrações no tempo (por dia + dia-da-semana × hora)",
        "description": "Cacheado (regenera quando o log run/updates/log muda). Conta os eventos 'cacheado+calibrado' do log append-only. run/ NÃO é versionado e pode rotacionar -> cobertura histórica parcial.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{calib_per_day:[{day,count}], calib_by_dow_hour:[{dow,hour,n}], total}"
          },
          "403": {
            "description": "não-admin (admin_required)"
          }
        }
      }
    },
    "/treino/admin/logout-user": {
      "post": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Desloga um usuário (remove sessões)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": {
                    "type": "string"
                  },
                  "logins": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{logged_out,sessions_removed}"
          }
        }
      }
    },
    "/treino/admin/lock-user": {
      "post": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Trava acesso (troca senha por aleatória + desloga)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": {
                    "type": "string"
                  },
                  "logins": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{locked,sessions_removed}"
          }
        }
      }
    },
    "/treino/admin/logout-ip": {
      "post": {
        "tags": [
          "treino-admin"
        ],
        "summary": "Desloga todas as sessões de um IP",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ip": {
                    "type": "string"
                  }
                },
                "required": [
                  "ip"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{logged_out,ip,sessions_removed,users,users_count}"
          }
        }
      }
    },
    "/treino/profile/password": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Troca a própria senha",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "old_password": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string"
                  }
                },
                "required": [
                  "old_password",
                  "new_password"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{updated:true}"
          },
          "403": {
            "description": "senha atual incorreta"
          }
        }
      }
    },
    "/treino/contest-registration": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Inscrição do próprio login num contest que usa as contas do treino (individual ou time de até 3 contas existentes): janela ancorada na PROVA OFICIAL (o aquecimento pode ficar dias no ar), minha situação, meu time e convites recebidos",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "contest",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{enabled,contest,contest_name,start_time,end_time,window:{state,opens_at,closes_at,late_until,official_start,official_round},round_kind,gate_active,team_max,teams_allowed,me,team,invites,totals}"
          },
          "409": {
            "description": "not_shared (contest sem USERS_FROM)"
          }
        }
      },
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Ações da inscrição: register {univ?,ai?,flag?} | individual-meta {univ?,ai?,flag?} (o INDIVIDUAL declara/edita universidade, IA e bandeira — paridade com o team-meta) | team-create {name,univ?,ai?,flag?} | team-invite {login} (o mojinho manda DM ao convidado com o link de aceitar/recusar) | team-accept | team-decline | team-rename | team-meta {univ?,ai?,flag?} | team-photo {image_b64}. Modo de participação é DEFINITIVO (403 mode_locked em cancel/leave/dissolve/troca); warmup também exige inscrição por padrão (REG_WARMUP_OPEN=y libera)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "action": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "login": {
                    "type": "string"
                  },
                  "team": {
                    "type": "string"
                  }
                },
                "required": [
                  "contest",
                  "action"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "estado completo da inscrição (mesmo shape do GET)"
          },
          "403": {
            "description": "registration_closed | registration_not_open"
          },
          "409": {
            "description": "registration_off | already_registered | in_team | team_full | name_taken"
          }
        }
      }
    },
    "/treino/profile/username": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Troca o próprio username (máx. 2/ano + cascata, incluindo TODAS as sessões do login — outra aba, outro dispositivo, token do moj-cli — que passam a valer com o nome novo sem deslogar; sufixo de papel é PRESERVADO — sufixo(novo)==sufixo(atual): .admin troca p/ outro.admin, uname_role_suffix se derrubar o sufixo, uname_reserved se assumir um)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "new_username": {
                    "type": "string"
                  }
                },
                "required": [
                  "new_username"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{updated:true,new_username,username_changes_used,username_changes_remaining,sessions_updated}"
          },
          "403": {
            "description": "limite anual atingido"
          },
          "409": {
            "description": "em uso ou submissões pendentes"
          }
        }
      }
    },
    "/submit": {
      "post": {
        "tags": [
          "submission"
        ],
        "summary": "Submeter solução (assíncrono). Gate por fase+papel: admin/judge sempre; staff nunca (403 submit_forbidden); normal e mon só durante a janela (403 contest_not_started / contest_ended; mon fica fora do placar). Whitelist de linguagens FORÇADA (400 lang_not_allowed): extensão canonicalizada tem de estar na lista efetiva do problema (override do contest -> LANGUAGES -> pacote -> todas) Teto SUBMIT_MAX_KB (1024) -> 413 source_too_large; whitelist vazia = as linguagens da PLATAFORMA (.exe -> 400); fail-closed: spool validado antes do OK (500 spool_write_failed, sem pendente fantasma)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "problem_id": {
                    "type": "string"
                  },
                  "filename": {
                    "type": "string",
                    "description": "Nome do arquivo da fonte. NORMALIZADO pelo servidor (safe_src_filename): sai o caminho, saem espaços, sai o '(N)' que o navegador gruda em download repetido (l(1).cpp -> l.cpp) e saem os metacaracteres de shell/make; acento é preservado. O juiz materializa a fonte com este nome e o entrega ao compilador."
                  },
                  "code_b64": {
                    "type": "string"
                  }
                },
                "required": [
                  "problem_id",
                  "code_b64"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{submission_id, status:\"queued\"}"
          }
        }
      }
    },
    "/submission/source": {
      "get": {
        "tags": [
          "submission"
        ],
        "summary": "Código-fonte de uma submissão",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "time",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "código (texto)",
            "content": {
              "text/plain": {}
            }
          }
        }
      }
    },
    "/submission/log": {
      "get": {
        "tags": [
          "submission"
        ],
        "summary": "Log do julgamento (report.html; expõe input+diff de todos os testes — dono vê conforme o SHOWLOG efetivo: explícito manda, ausente = oculto em modo icpc)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "time",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "log (texto)",
            "content": {
              "text/plain": {}
            }
          }
        }
      }
    },
    "/submission/summary": {
      "get": {
        "tags": [
          "submission"
        ],
        "summary": "Resumo estruturado em lote (detalhe sob o veredicto canônico), REDIGIDO pelo modo do contest",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "ids",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "csv de submission ids (até 1000)"
          }
        ],
        "responses": {
          "200": {
            "description": "{\"<id>\":{verdict,verdict_canon,score,score_max,score_kind,correct,total,groups,heur_score?,heur_adjusted?}} — redigido por modo (lib/verdict.sh): full (treino/lista)=tudo; score (obi/heurístico/outro)=canônico+score/groups/heur sem correct/total; none (icpc/ausente)=só o canônico (nos níveis redigidos verdict=canônico; juiz/admin sempre full com verdict cru). groups=[{earned,max},…] na ordem do tests/score (earned null = grupo não executado; results antigos: canon derivado da string, groups da cauda 'Pontos | … |' com max:null). Mesmo gate do log; ids de terceiros omitidos"
          }
        }
      }
    },
    "/contest/basic": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Info pública (nome, horários, login_start_time)",
        "description": "Traz também balloon_style (icon|fill, default icon): como o placar pinta a célula de quem resolveu. icon = fundo neutro igual para todos + um ponto com a cor do balão, de modo que \"resolvido\" não dependa de enxergar a cor — a paleta ICPC dá o problema A BRANCO, e branco sobre o fundo branco do placar é o mesmo pixel (1,00:1), então o acerto sumia. fill = o clássico, cor do balão no fundo, mais um contorno derivado da própria cor para as claras não sumirem. Vale para o placar ao vivo, a cerimônia de revelação e o relatório offline. Ver docs/SCOREBOARD.md.",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{contest_id,contest_name,start_time,end_time,login_start_time,locale,login_enabled,freeze_time,score_anon,languages[],secret} — languages = whitelist do conf LANGUAGES= ([] = todas); locale = pt|en explícito (IMPÕE o idioma da UI do contest) ou \"\" quando não setado (o front cai no seletor/idioma do browser)"
          }
        }
      }
    },
    "/contest/userinfo": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Info do usuário logado",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{login,name,…}"
          }
        }
      }
    },
    "/contest/navbuttons": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Botões de navegação por papel (.admin/.judge/.staff/.cstaff/.mon; .cstaff ganha Etiquetas e — pós-fim p/ todas as sedes — Revelação; .staff sem Etiquetas)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "[{label,url}]"
          }
        }
      }
    },
    "/contest/statement": {
      "get": {
        "tags": ["contest"],
        "summary": "UM enunciado da prova, cru (html ou pdf), sob demanda",
        "description": "Gate IDENTICO ao de /contest/problems (can_see_problems): .staff/.cstaff nunca, competidor so depois do inicio, admin/juiz sempre — e a recusa e 404, nao 403: pedir o enunciado direto nao pode confirmar que o problema existe antes de a prova abrir. A chave do arquivo sai sempre do PROBS do conf, nunca do parametro. Responde ETag (mtime+tamanho) + Cache-Control: private, max-age=60 e honra If-None-Match com 304 — recarregar a pagina nao repuxa MB, e enunciado corrigido no meio da prova invalida sozinho.",
        "security": [{"bearerAuth": []}],
        "parameters": [
          {"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}},
          {"name": "problem", "in": "query", "required": true, "description": "letra (short_name) ou problem_id canonico", "schema": {"type": "string"}},
          {"name": "format", "in": "query", "description": "html (default) ou pdf", "schema": {"type": "string", "enum": ["html", "pdf"]}}
        ],
        "responses": {
          "200": {"description": "o enunciado (text/html; charset=utf-8 ou application/pdf)"},
          "304": {"description": "If-None-Match casou o ETag — corpo vazio"},
          "400": {"description": "problem ausente ou format invalido"},
          "404": {"description": "sem permissao de ver, problema inexistente ou sem esse formato"}
        }
      }
    },
    "/contest/problems": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Problemas da prova. Gate: admin/judge veem sempre; staff nunca; normal só após o início (antes -> {problems:[], locked:'not_started'})",
        "description": "Resposta em CACHE por VARIANTE (author|noauthor). O payload é o mesmo para todo time; a única dimensão que o muda é o AUTOR do problema, que só aparece com a prova encerrada ou para admin/juiz-chefe/juiz — o cache por variante existe para que um GET do juiz nunca entregue o autor a um competidor. Invalida comparando com as entradas (conf, problem-langs.json, problem-judges.json, enunciados/, carimbo var/.problems-dirty tocado pelo admin e pela promocao de rodada) mais um teto de idade de 60s. O CORPO DO ENUNCIADO NAO VEM AQUI (2026-08-20): a lista traz apenas has_statement_html/has_statement_pdf e o conteudo sai por /contest/statement, quando a pessoa abre a sanfona ou clica em HTML/PDF. Vinha em base64, e num contest de PDF isso e 3,8 MB por time — 2000 times abrindo a prova ao mesmo tempo eram ~5 GB de uma vez.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[{short_name,full_name,problem_id,statement_html_b64,statement_pdf_b64,time_limits,languages}]} (time_limits = MAX entre juízes; com pool de juízes definido — problem-judges.json -> CONTEST_JUDGES — só entre os hosts do pool efetivo. languages = ids permitidos: override por-problema-no-contest -> whitelist do contest -> default do próprio pacote (.moj-meta.json) -> [] (todas))"
          }
        }
      }
    },
    "/contest/news": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Notícias do contest (opcional)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/contest/updates": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Resumo de novidades p/ polling (notícias + clarifications não lidas)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{news:{last,count,unread}, clar:{last,count,unread}}"
          }
        }
      }
    },
    "/contest/backup": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Lista os backups do próprio usuário",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{backups:[{id,name,size,time}]}"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Guarda/remove um backup de arquivo (versão de solução)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{filename,file_b64} guarda; {action:remove,id} remove"
          }
        }
      }
    },
    "/contest/backup-file": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Baixa um backup (próprio; login= só admin)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "arquivo (octet-stream)"
          }
        }
      }
    },
    "/contest/print": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Lista os pedidos de impressão do próprio usuário (+ flags staff_exists/allow_print)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{requests:[{id,seq,filename,mime,size,time,status,pages}], staff_exists, allow_print}"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Cria um pedido de impressão (gera nº sequencial)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{id,seq,status:pending} — body {filename,file_b64}, máx 10MB"
          },
          "403": {
            "description": "print_unavailable (sem staff) | print_disabled (PRINT=0)"
          }
        }
      }
    },
    "/contest/print-file": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Baixa o arquivo cru de um pedido (dono/admin/staff no escopo; .cstaff não — 403)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "arquivo (Content-Disposition)"
          }
        }
      }
    },
    "/contest/staff/queue": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Fila de tarefas do staff (impressão + balões; escopo por staff-filters; .cstaff SÓ LÊ — ações/PDF 403). Reconcilia balões pendentes (1ª-AC) ao carregar. Cada balão traz first_site: é o primeiro daquela cor NA SEDE do time (★ na fila e na folha) — só sai decidido; havendo run mais antiga da sede por julgar, a tarefa espera até BALLOON_FIRST_WAIT_S e depois sai sem estrela",
        "description": "FREEZE: acerto com sub_epoch >= FREEZE_TIME NÃO vira tarefa de balão (o balão andando pela sala conta o que o placar congelado esconde) e a supressão é registrada em print-requests/.balloon-frozen — permanente, para que encerrar o evento (FREEZE_TIME=0) não a desfaça. Só o admin desfaz, ligando balloons_during_freeze no settings. Pedido de impressão NÃO é afetado pelo freeze. balloons_frozen (contagem dos suprimidos) só vai para o admin.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{requests:[{id,seq,kind,login,team,univ,short,color_hex,color_name,filename,status,…}], balloons_frozen}"
          }
        }
      }
    },
    "/contest/staff/print-action": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Máquina de estado da tarefa: claim|processed|delivered (.staff/admin; .cstaff não — 403)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{updated:{id,seq,status,…}} — body {id,action,mode?}"
          },
          "409": {
            "description": "already_claimed | not_pending | not_printed"
          }
        }
      }
    },
    "/contest/staff/print-pdf": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "PDF inline (build-once): kind=print → folha de rosto + documento A4 (código numerado); kind=balloon → folha do balão (time/univ/login/problema/cor desenhada+nome/nº/assinatura). .staff/admin; .cstaff não — 403",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "application/pdf"
          }
        }
      }
    },
    "/contest/badges": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Credenciais p/ etiquetas imprimíveis (senha em claro — auditado badges-view). A lista sai SÓ do store local do contest, nunca da fonte USERS_FROM, e a senha só sai quando é credencial do contest que funciona: conta compartilhada vem com password vazia + shared_credential:true, e conta DESABILITADA vem com password vazia + disabled:true mesmo com include_disabled=1 (o ! do user-disable é aleatório; o de time é um uuid interno). Incidente de 2026-08-18. Admin/.cstaff apenas — .staff recebe 403 cstaff_required. Admin: tudo ou o arquivo de uma sede via staff=<login .cstaff>; .cstaff: só o próprio recorte (staff-filters) + as .staff do mesmo escopo — só o .staff ganha etiqueta (nenhuma conta .cstaff entra, nem a de quem olha). include_disabled=1 inclui contas desativadas",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{users:[{login,name,fullname,password,shared_credential,disabled,region,univ}], staff:[…só admin, logins .cstaff], regions, shared, contest_name, start_epoch} — shared = fonte USERS_FROM (vazio em contest de contas próprias)"
          },
          "403": {
            "description": "cstaff_required | staff_scope"
          }
        }
      }
    },
    "/contest/admin/staff-filters": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Escopo de cada usuário .staff/.cstaff (no .cstaff vale p/ fila-leitura, etiquetas e cerimônia) + lista de staff/regiões",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{staff:[{login,fullname,disabled}], filters:{login:[regex]}, regions:[{name,regex}]}"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Grava o escopo por staff/cstaff (entradas region:<nome> — igualdade com a sede do time — ou regex no login; vazio = vê tudo)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved:true,filters} — body {filters:{login:[regex]}}"
          }
        }
      }
    },
    "/contest/admin/preflight": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Checklist pré-prova, a lista da 🏁 Central do painel de admin (janela, SHOWLOG, freeze, juízes online, pool de juízes — estrito: fail se o pool do contest está todo offline; pool_problems p/ overrides por problema — toolchains e TL calibrado no pool efetivo, staff e o ESCOPO do staff, contas (sem contas de papel), balões sem cor, coortes, gate de navegador por sede, rodada seguinte com os bloqueadores, documentos gerados/publicados, prorrogação × freeze, spool)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{checks:[{id,level:ok|warn|fail,label,detail}], summary:{ok,warn,fail}} — ids: window, show_log, show_code, freeze, judges, pool, langs, problems, pool_problems, print, staff_filters, balloons, users, daemon, mode, manual, tov, cohorts, ua_gate, next_round, docs"
          }
        }
      }
    },
    "/contest/admin/time-overrides": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Prorrogação de vigência por sede/grupo: regras [{regex,end,reason?}] no login (1ª que casa estende o fim SÓ daquele grupo)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{rules:[{regex,end,reason}], contest_end, regions} — aplicado no /submit e no countdown (contest_end_effective)"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Substitui as regras de prorrogação (valida regex/end; só estende, nunca encurta; auditado)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved:true,rules} — body {rules:[{regex,end,reason?}]}"
          }
        }
      }
    },
    "/contest/admin/backups": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Lista todos os backups (filtros user/q) + resumo por usuário",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{backups:[{login,id,name,size,time}],users:[{login,count,bytes}]}"
          }
        }
      }
    },
    "/contest/admin/backup-zip": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Zip com todos os backups de um usuário",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "application/zip"
          }
        }
      }
    },
    "/contest/nutella": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Panorama das máquinas mlinux (nutellaboot) — admin/chefe tudo; .cstaff/.staff a própria sede; ?catalog=<image> = catálogo de comandos",
        "responses": {
          "200": {
            "description": "{configured, url, status, can_admin, scoped, data:{version:2, contest, link, global, by_node, sedes[]}} — agregados por recorte (pop, ram_bands, ed_adopt/ed_groups/ed_count/profiles, pressure, rank_ed, series); ver docs/NUTELLABOOT.md"
          },
          "403": {
            "description": "role_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Ações da integração nutellaboot: config (chave write-only)/collect/push-roster (admin) e command (admin ou c/staff na própria sede, fail-closed)",
        "responses": {
          "200": {
            "description": "config:{saved,configured} · collect:{started} · push-roster:{pushed,kept,failed} · command:{sent,op,image,mac,upstream}"
          },
          "403": {
            "description": "admin_required/site_forbidden/command_scope_required"
          },
          "409": {
            "description": "not_configured/no_cache"
          },
          "422": {
            "description": "key_invalid/op_invalid/op_not_allowed/image_invalid"
          },
          "502": {
            "description": "upstream_error — nutellaboot indisponível"
          }
        }
      }
    },
    "/contest/admin/finish": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Checklist pós-prova (o que ainda está fechado) — admin ou juiz-chefe",
        "responses": {
          "200": {
            "description": "{checks:[{id,level,label,detail}], summary:{ok,warn,fail}, can_finish, can_act (true só p/ o admin — o POST é dele), pending_docs:[{type,lang}]}"
          },
          "403": {
            "description": "admin_required (nem admin nem juiz-chefe)"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Encerrar evento: abre o placar (FREEZE_TIME=0) e publica os documentos gerados — admin",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "finish"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{finished:true, done:[{item,detail}], skipped:[{item,reason}]}"
          },
          "400": {
            "description": "bad_json/action_invalid"
          },
          "403": {
            "description": "admin_required"
          },
          "409": {
            "description": "contest_running — a prova ainda não acabou para todas as sedes"
          },
          "429": {
            "description": "busy"
          }
        }
      }
    },
    "/contest/admin/report": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Relatório estático da prova (tar.gz navegável offline: placar aberto, runs, enunciados, clarifications anônimas, estatísticas, tarefas do staff, infra)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "application/gzip (relatorio-<c>-<data>.tar.gz)"
          },
          "429": {
            "description": "busy — já existe uma geração em andamento"
          }
        }
      }
    },
    "/contest/news-file": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Baixa o anexo de uma notícia",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "arquivo (octet-stream, Content-Disposition: attachment)"
          }
        }
      }
    },
    "/contest/doc": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Documentos da prova em pt/en/es: lista (sem type) ou download (com type; o PDF enviado pelo admin vence o gerado)",
        "description": "Sem `type`: lista os documentos que o login pode baixar. Com `type`: serve o arquivo (PDF/HTML). Idiomas pt/en/es. Em fmt=pdf o documento ENVIADO pelo admin (docs/<tipo>.<lang>.uploaded.pdf) vence o gerado; fmt=html e sempre o gerado. Admin e juiz-chefe sempre; qualquer outro login so o que estiver PUBLICADO (404 not_published).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "info-sheet",
                "contest",
                "times",
                "editorial"
              ]
            }
          },
          {
            "name": "lang",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "pt",
                "en",
                "es"
              ]
            }
          },
          {
            "name": "fmt",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "pdf",
              "enum": [
                "pdf",
                "html"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{docs:[{type,lang,html_bytes,pdf_bytes,generated_at,by,published}], can_manage} ou o arquivo"
          },
          "404": {
            "description": "not_published | not_generated"
          }
        }
      }
    },
    "/contest/rounds": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Rodadas do contest (aquecimento / prova oficial) visiveis a este login",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{active, can_manage, rounds:[{slug,name,kind,state,start,end,published,stats,has_report}]}"
          }
        }
      }
    },
    "/contest/round": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Serve o relatorio arquivado de uma rodada (site estatico)",
        "description": "Admin/juiz/chief/staff/cstaff/mon sempre; demais logins so rodada publicada (404 not_published). O caminho e confinado ao diretorio do relatorio.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "round",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "file",
            "in": "query",
            "schema": {
              "type": "string",
              "default": "index.html"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "o arquivo (HTML/PDF/CSS/JS/imagem)"
          },
          "404": {
            "description": "not_published | no_report | file_notfound | round_notfound"
          }
        }
      }
    },
    "/contest/resources": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Materiais do contest (opcional)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/contest/history": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Histórico do usuário (TXT)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "TXT: tempo:user:probid:lang:veredito:epoch:subid — veredito (campo 5) SEMPRE canônico em TODOS os modos (a string com score/grupos fica no disco; o detalhe por modo vem do /submission/summary redigido). Pendentes/strings desconhecidas intactos; sufixo ' (Ignored)' preservado",
            "content": {
              "text/plain": {}
            }
          }
        }
      }
    },
    "/contest/balloons": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Cores de balão por problema",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "mapa letra→cor"
          }
        }
      }
    },
    "/contest/regions": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Regiões p/ filtro do placar",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/contest/score": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Placar (TXT, 1ª linha = modo); antes do início serve a VITRINE (times sem colunas de problema — o placar nunca revela a quantidade de problemas antes de a competição começar; is_judge vê o completo); privilegiado recebe o completo — view=public força o congelado (usado pela cerimônia de revelação); scope=mine (só .cstaff) recorta à sede",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "view",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "VISÃO do placar (coortes): public = visão pública/congelada mesmo com sessão privilegiada; oficial = só as coortes públicas; geral = tudo (só p/ quem já pode ver tudo); <id de coorte pública com ranking> = o placar paralelo dela (ex.: individual, times) — é público, não exige sessão. Valor inválido cai na visão do login."
          },
          {
            "name": "scope",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "mine"
              ]
            },
            "description": "mine (honrado só p/ .cstaff) = recorta o placar servido (frozen e full) aos usuários do escopo dele (staff-filters); fora da allowlist SCORE_FULL_USERS, o full só sai quando o contest terminou p/ TODAS as sedes (fim do conf + maior end de time-overrides.json) — cerimônia por sede"
          }
        ],
        "responses": {
          "200": {
            "description": "TXT",
            "content": {
              "text/plain": {}
            }
          },
          "401": {
            "description": "contest SUPER SECRETO sem sessão do contest (secret_login_required)"
          }
        }
      }
    },
    "/contest/allsubmissions": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Todas as submissões (TXT 9 campos; admin/chief completo; judge/mon ANÔNIMO — username/fullname/univ vazios, ordenado por epoch)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "TXT",
            "content": {
              "text/plain": {}
            }
          }
        }
      }
    },
    "/contest/final-verdicts": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Opções de veredicto manual (judge): {verdicts:[labels], options:[{label,verdict}]}",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Grava as opções de veredicto (admin/chief): {options:[{label,verdict}]}",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved,options}; 403 config_forbidden"
          }
        }
      }
    },
    "/contest/auto-verdicts": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Matriz de veredicto automático problema×lang×veredicto (judge)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{matrix,problems,verdicts}"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Grava a matriz auto (admin/chief): {matrix:{cid:{lang|*:[verdict]}}}",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved,matrix}; 403 config_forbidden"
          }
        }
      }
    },
    "/contest/review/list": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Fila de revisão de veredicto manual (judge; votos detalhados e login do competidor só p/ admin/chief — anti-anchoring/anonimato)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{manual,options,items,counts:{not_evaluated,being_evaluated,awaiting_second,conflicts},my_active}"
          }
        },
        "description": "Resposta inclui quorum (REVIEW_JUDGES do contest)."
      }
    },
    "/contest/review/claim": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Pega/renova/larga avaliação (judge): {id,action:claim|extend|giveup}; máx 2, 1 ativa, TTL 5min",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{updated}; 409 already_evaluating|slots_full"
          }
        }
      }
    },
    "/contest/review/vote": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Vota o veredicto (judge): {id,label}; voto permanente e libera o juiz (pega outra); 2 iguais liberam, 2 diferentes = conflito",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{status,released_verdict?}"
          }
        }
      }
    },
    "/contest/review/resolve": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Juiz-chefe resolve conflito (admin/chief): {id,verdict}",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{status:released,released_verdict}; 403 chief_required"
          }
        }
      }
    },
    "/contest/review/conflicts": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Sumário de conflitos de veredicto p/ o juiz-chefe (admin/chief); n alimenta o alerta global",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{conflicts:[{id,login,problem_id,lang,sub_epoch,computed_verdict,votes}], n, options}"
          }
        }
      }
    },
    "/contest/review/stats": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Estatística por .judge da avaliação manual (admin/chief): votos, tempo médio, concordâncias, conflitos",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{judges:[{judge,votes,avg_response_s,timed,agreements,conflicts}], total:{votes,avg_response_s}}"
          }
        }
      }
    },
    "/contest/set-verdict": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Definir veredicto final (admin ou juiz-chefe)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "problem_id": {
                    "type": "string"
                  },
                  "verdict": {
                    "type": "string"
                  },
                  "username": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/contest/rejudge": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Rejulgar (admin): reconstrói a fonte arquivada + reinjeta como submit",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{action:rejudge, queued[], count, skipped[], skipped_count} (skipped = sem fonte/linha no history)"
          }
        }
      }
    },
    "/admin/adduser": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Adicionar usuário",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "login": {
                    "type": "string"
                  },
                  "fullname": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{login,password}"
          }
        }
      }
    },
    "/admin/passwd": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Trocar senha",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "login": {
                    "type": "string"
                  },
                  "newpass": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/admin/contest/extend": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Estender fim do contest",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "end_epoch": {
                    "type": "integer"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/admin/synctreino": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Sincronizar treino",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/admin/rejudge": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Rejulgar (alias do bot)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  },
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "problem": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/ops/queue": {
      "get": {
        "tags": [
          "ops"
        ],
        "summary": "Tamanho da fila por contest",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/ops/problemtl": {
      "get": {
        "tags": [
          "ops"
        ],
        "summary": "Time limits de um problema",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "problem",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/ops/updateproblemset": {
      "post": {
        "tags": [
          "ops"
        ],
        "summary": "Atualizar problemset nos juízes",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "repo": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          }
        }
      }
    },
    "/index/status": {
      "get": {
        "tags": [
          "index"
        ],
        "summary": "Health público (fila, máquinas, daemons)",
        "responses": {
          "200": {
            "description": "{queue,judge:{online,total,busy,healthy,cpus_online,gpus_online},alert,daemons} — cache 20s; gpus_online = só GPU de compute comprovada (nvidia-smi/rocm-smi); daemons.judged = processo local ou heartbeat run/judged.alive (deploy em container) + bot:{alive,last_poll_age_s}|null (mojinho; null = sem bot)"
          }
        },
        "description": "judge.slots = capacidade total em SLOTS (multi-slot); judge.busy = slots ocupados."
      }
    },
    "/treino/contest-create/permission": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "O usuário pode criar contest?",
        "responses": {
          "200": {
            "description": "{can_create,is_admin,reason,solved_count,threshold,in_allow,in_deny,allowed_modes}"
          }
        }
      }
    },
    "/treino/contest-create/problems": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Busca no banco público de problemas",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[{id,title,tags}],total,bank_total}"
          }
        }
      }
    },
    "/treino/contest-create/create": {
      "post": {
        "tags": [
          "contest-create"
        ],
        "summary": "Cria e publica o contest (no ar na hora)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "icpc",
                      "obi",
                      "treino",
                      "heuristic",
                      "outro"
                    ]
                  },
                  "start": {
                    "type": "integer"
                  },
                  "end": {
                    "type": "integer"
                  },
                  "priority": {
                    "type": "string",
                    "enum": [
                      "prova",
                      "lista-privada",
                      "lista-publica",
                      "super"
                    ],
                    "description": "prioridade no escalonador ('super' só admin do treino)"
                  },
                  "languages": {
                    "description": "array de ids canônicos (normalizado como o settings) ou string legada",
                    "oneOf": [
                      {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      {
                        "type": "string"
                      }
                    ]
                  },
                  "judges": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "pool de juízes do contest (hostnames do registro; vazio = qualquer juiz online) — vira CONTEST_JUDGES; escalonador ESTRITO"
                  },
                  "showcode": {
                    "type": "boolean"
                  },
                  "show_log": {
                    "type": "boolean"
                  },
                  "show_editor": {
                    "type": "boolean"
                  },
                  "show_tl": {
                    "type": "boolean"
                  },
                  "allow_backup": {
                    "type": "boolean"
                  },
                  "allow_print": {
                    "type": "boolean"
                  },
                  "score_anon": {
                    "type": "boolean"
                  },
                  "manual_verdict": {
                    "type": "boolean"
                  },
                  "allow_late": {
                    "type": "boolean"
                  },
                  "secret": {
                    "type": "boolean",
                    "description": "SUPER SECRETO: fora das listagens públicas; placar/visual exigem login no contest"
                  },
                  "login_ua_substring": {
                    "type": "string",
                    "maxLength": 200
                  },
                  "score_full_users": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "penalty_minutes": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "placar ICPC: minutos por tentativa que conta antes do AC (default 20)"
                  },
                  "penalty_verdicts": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "wa",
                        "tle",
                        "mle",
                        "rte",
                        "ce"
                      ]
                    },
                    "description": "placar ICPC: verdicts que contam penalidade (default wa/tle/mle/rte; Judge Error e pendentes nunca contam; [] = nenhum penaliza)"
                  },
                  "locale": {
                    "type": "string",
                    "enum": [
                      "pt",
                      "en",
                      "es"
                    ]
                  },
                  "login_start": {
                    "type": "integer"
                  },
                  "login_enabled": {
                    "type": "boolean"
                  },
                  "freeze": {
                    "type": "integer"
                  },
                  "problems": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "description": "bank_id OU source+problem_id; name/letter; statement_b64/statement_file, statement_pdf_b64/statement_pdf_file; languages[] por problema (vira problem-langs.json). Ao menos um item — ou allow_empty:true"
                    }
                  },
                  "allow_empty": {
                    "type": "boolean",
                    "description": "cria o contest SEM problemas (configura depois por /contest/admin/problems). Sem ele, problems[] vazio ⇒ 422 no_problems. Só o booleano true vale (1/\"true\" não)"
                  },
                  "admin": {
                    "type": "object",
                    "properties": {
                      "login": {
                        "type": "string"
                      },
                      "password": {
                        "type": "string"
                      },
                      "fullname": {
                        "type": "string"
                      }
                    }
                  },
                  "users_from": {
                    "type": "string",
                    "description": "id de contest cujos usuários são compartilhados (ex.: treino)"
                  },
                  "users": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "login": {
                          "type": "string"
                        },
                        "password": {
                          "type": "string"
                        },
                        "fullname": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "colors": {
                    "type": "object",
                    "description": "mapa letra->hex (sem #) + enableSonic?"
                  },
                  "regions": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "teams_meta": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "regex": {
                          "type": "string"
                        },
                        "country": {
                          "type": "string"
                        },
                        "school": {
                          "type": "string"
                        },
                        "school_full": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "required": [
                  "name",
                  "end"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{contest_id,admin_login,admin_reused,admin_password,url,scoreboard_url}"
          },
          "403": {
            "description": "sem permissão"
          },
          "409": {
            "description": "id em uso/reservado"
          },
          "422": {
            "description": "validação (no_problems = problems[] vazio sem allow_empty)"
          }
        }
      }
    },
    "/treino/contest-create/template": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Baixa template JSON do contest",
        "responses": {
          "200": {
            "description": "arquivo JSON (attachment)"
          }
        }
      }
    },
    "/treino/contest-create/import": {
      "post": {
        "tags": [
          "contest-create"
        ],
        "summary": "Importa contest de um .tar.gz",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "tar_b64": {
                    "type": "string"
                  }
                },
                "required": [
                  "tar_b64"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "criado"
          },
          "400": {
            "description": "tar inválido"
          }
        }
      }
    },
    "/treino/admin/contest-perms": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "Lê permissões de criação de contest",
        "responses": {
          "200": {
            "description": "{perms:{threshold,allow[],deny[]}}"
          }
        }
      },
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Define permissões de criação de contest",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "threshold": {
                    "type": "integer"
                  },
                  "allow": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "deny": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{saved,perms}"
          }
        }
      }
    },
    "/treino/admin/contests": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "Lista contests criados pela interface",
        "responses": {
          "200": {
            "description": "{contests:[{id,name,owner,created_at,mode}],count}"
          }
        }
      }
    },
    "/treino/admin/contest-remove": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Move um contest criado pela interface p/ lixeira",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "contest": {
                    "type": "string"
                  }
                },
                "required": [
                  "contest"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{removed,contest}"
          },
          "403": {
            "description": "não criado pela interface"
          }
        }
      }
    },
    "/treino/contest-create/tags": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Tags do banco com contagem",
        "responses": {
          "200": {
            "description": "{tags:[{tag,count}],total}"
          }
        }
      }
    },
    "/treino/contest-create/templates": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Templates nomeados do criador (?name= => 1; senão lista)",
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{templates:[{name,created_at,updated_at,mode,duration,has_problems}]} ou {template:{name,spec}}"
          },
          "404": {
            "description": "template não encontrado"
          }
        }
      },
      "post": {
        "tags": [
          "contest-create"
        ],
        "summary": "save|delete|rename template (save: template{} do formulário OU from_contest; spec relativizado + whitelist — nunca guarda usuários/senhas/id)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "op": {
                    "type": "string",
                    "enum": [
                      "save",
                      "delete",
                      "rename"
                    ]
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 80
                  },
                  "template": {
                    "type": "object"
                  },
                  "from_contest": {
                    "type": "string",
                    "description": "salvar a partir de contest existente (só dono/admin; senão 404)"
                  },
                  "include_problems": {
                    "type": "boolean"
                  },
                  "new_name": {
                    "type": "string"
                  }
                },
                "required": [
                  "op",
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{saved|deleted|renamed:true}"
          },
          "404": {
            "description": "template/from_contest não encontrado (não vaza existência)"
          }
        }
      }
    },
    "/treino/contest-create/export": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Exporta o spec JSON de um contest (round-trip com o create; nunca exporta passwd/users/senhas)",
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "full_statements",
            "in": "query",
            "description": "1 = embute todos os enunciados; default embute só o material exclusivo do contest",
            "schema": {
              "type": "integer",
              "enum": [
                0,
                1
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "attachment <id>-spec.json"
          },
          "404": {
            "description": "contest inexistente, sem created-by, ou o login não é dono/admin"
          }
        }
      }
    },
    "/treino/contest-create/duplicate": {
      "post": {
        "tags": [
          "contest-create"
        ],
        "summary": "Cria contest novo a partir de existente (conf+problemas+visual; usuários/submissões nunca; datas relativas preservadas)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "from": {
                    "type": "string"
                  },
                  "id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "start": {
                    "type": "integer"
                  },
                  "end": {
                    "type": "integer"
                  },
                  "admin": {
                    "type": "object"
                  },
                  "users": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "users_from": {
                    "type": "string"
                  }
                },
                "required": [
                  "from"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "mesmo retorno do create"
          },
          "404": {
            "description": "from inexistente/sem acesso (dono ou admin)"
          }
        }
      }
    },
    "/treino/contest-create/mine": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Contests criados pelo login (owner + created-by)",
        "responses": {
          "200": {
            "description": "{contests:[{id,name,mode,created_at,start,end,problems_count}],total}"
          }
        }
      }
    },
    "/treino/contest-create/collections": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Coleções do banco público com contagem (escopo do wizard; ≠ /problems/collections)",
        "responses": {
          "200": {
            "description": "{collections:[{collection,count}],total}"
          }
        }
      }
    },
    "/treino/contest-create/genpass": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "N senhas legíveis",
        "parameters": [
          {
            "name": "n",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{passwords[]}"
          }
        }
      }
    },
    "/treino/contest-create/draw": {
      "get": {
        "tags": [
          "contest-create"
        ],
        "summary": "Sorteia problemas por tag, coleção e dificuldade (filtros em AND; reproduzível por seed)",
        "parameters": [
          {
            "name": "tags",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "collections",
            "in": "query",
            "description": "array JSON url-encoded de nomes de coleção (casa exato; inválido/ausente = sem filtro)",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "count",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "match",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "any",
                "all"
              ]
            }
          },
          {
            "name": "difficulty",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "any",
                "easy",
                "medium",
                "hard",
                "known"
              ]
            }
          },
          {
            "name": "seed",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{problems[],candidates,drawn,seed}"
          }
        }
      }
    },
    "/contest/teams-meta": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Regras regex país/escola p/ o placar (bandeiras locais; fallback — o por-usuário /contest/teams vence)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{rules:[{regex,country,school,school_full}]}"
          }
        }
      }
    },
    "/contest/teams": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Diretório de TIMES por-usuário p/ o placar (do .team do account.json + assets; secreto exige sessão)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{teams:{<login>:{univ_short?,univ_full?,flag?,region?,has_logo,has_photo}}} — o NOME vem do TXT do placar (fullname); precedência: isto > teams-meta > vazio"
          }
        }
      }
    },
    "/contest/team-photo": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Foto do time — PÚBLICA, inclusive em contest secreto (webp; png no acervo antigo; thumb=1 = miniatura de 320px). TIME SEM FOTO devolve a FOTO PADRÃO do contest (200 + header X-MOJ-Photo: placeholder), não 404 — quem precisa saber quem mandou foto usa has_photo das listagens ⚠ em contest SUPER SECRETO o <img>/<audio> cru não serve (tag de mídia não manda Authorization): o cliente tem de BUSCAR com Bearer — o front usa web/shared/media-auth.js, que converte em blob:",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "user",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "thumb",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "1 = miniatura 320px (Cache-Control longo; o cliente manda &v=<mtime>)"
          }
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "image/webp ou image/png; sem foto do time vem a padrão (X-MOJ-Photo: placeholder)"
          }
        }
      }
    },
    "/contest/team-music": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Música do time — PÚBLICA, inclusive em contest secreto (mp3 — a faixa que o telão toca quando ele resolve). TIME SEM MÚSICA devolve a MÚSICA PADRÃO do contest (200 + header X-MOJ-Music: placeholder), não 404 — quem precisa saber quem mandou a sua usa has_music das listagens. Sem suporte a Range (toca progressivo) ⚠ em contest SUPER SECRETO o <img>/<audio> cru não serve (tag de mídia não manda Authorization): o cliente tem de BUSCAR com Bearer — o front usa web/shared/media-auth.js, que converte em blob:",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "user",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "audio/mpeg (+ Content-Length); sem música do time vem a padrão (X-MOJ-Music: placeholder)"
          }
        }
      }
    },
    "/contest/team-logo": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Brasão do time (PNG, máx 128; célula do time no placar — vence o logo do teams-meta; secreto exige sessão) ⚠ em contest SUPER SECRETO o <img>/<audio> cru não serve (tag de mídia não manda Authorization): o cliente tem de BUSCAR com Bearer — o front usa web/shared/media-auth.js, que converte em blob:",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "user",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "image/png (404 sem brasão)"
          }
        }
      }
    },
    "/contest/webcast": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "ZIP do placar no protocolo do Animeitor (= webcast.php do BOCA). SEM SESSÃO: quem autoriza é a chave, que declara a visão de coorte. Pacote sempre DESCONGELADO — formato em docs/WEBCAST.md",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "key",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "chave mojwc_… criada pelo .animeitor; inválida/revogada/ausente → 404"
          }
        ],
        "responses": {
          "200": {
            "description": "application/zip (contest/runs/time/version/icpc; campos separados por 0x1C)"
          },
          "404": {
            "description": "chave inválida (não confirma sequer que o contest existe)"
          }
        }
      }
    },
    "/contest/animeitor/photos": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Galeria das fotos dos times (.animeitor ou admin do contest)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{teams:[{login,name,univ,cohort,region,flag,has_photo,format,bytes,mtime,has_music,music_bytes,music_mtime}], total, with_photo, with_music, scoped, placeholder:{custom,mtime,music_custom,music_mtime}} — UMA varredura (find|xargs jq) para foto E música, pensada p/ 1000+ times. Para a sede (.cstaff/.staff) a lista vem recortada nela (scoped:true); o .staff entra em modo leitura"
          },
          "403": {
            "description": "animeitor_required"
          }
        }
      }
    },
    "/contest/animeitor/photo": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Sobe/troca/remove a foto de um time (gravada em webp; login aceita nome de arquivo, que é como o envio em lote funciona). O .cstaff só na própria sede (403 staff_scope)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": { "type": "string" },
                  "filename": { "type": "string" },
                  "file_b64": { "type": "string" },
                  "action": { "type": "string", "enum": ["delete"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{saved:true,login,bytes,format} ou {deleted:true,login}"
          },
          "403": {
            "description": "animeitor_required"
          },
          "404": {
            "description": "nenhum time casa com o login/arquivo"
          }
        }
      }
    },
    "/contest/animeitor/music": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Sobe/troca/remove a MÚSICA de um time (mp3 validado por MIME, máx 15MB; login aceita nome de arquivo, que é como o envio em lote funciona). O .cstaff só na própria sede (403 staff_scope)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": { "type": "string" },
                  "filename": { "type": "string" },
                  "file_b64": { "type": "string" },
                  "action": { "type": "string", "enum": ["delete"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{saved:true,login,bytes} ou {deleted:true,login}"
          },
          "400": {
            "description": "music_bad (o arquivo não é audio/mpeg) | file_b64 | file_missing"
          },
          "403": {
            "description": "animeitor_required"
          },
          "404": {
            "description": "nenhum time casa com o login/arquivo"
          },
          "413": {
            "description": "file_large (máx 15MB)"
          }
        }
      }
    },
    "/contest/animeitor/photos-zip": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Pacote do telão: fotos/<login>.webp para TODOS os times (quem não mandou leva a padrão) + musicas/<login>.mp3 só de quem mandou + placeholder.webp + placeholder.mp3 + teams.csv (login,nome,universidade,coorte,bandeira,foto,padrao,musica,musica_padrao). Para o .cstaff sai recortado na sede dele",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "application/zip"
          },
          "403": {
            "description": "animeitor_required"
          }
        }
      }
    },
    "/contest/placeholder": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "PADRÃO do contest — o que a API responde no lugar do asset de quem não mandou o seu: kind=photo (default) = a foto padrão, kind=music = a música padrão. Escolhido pelo .animeitor; sem escolha, o de fábrica do repo ⚠ em contest SUPER SECRETO o <img>/<audio> cru não serve (tag de mídia não manda Authorization): o cliente tem de BUSCAR com Bearer — o front usa web/shared/media-auth.js, que converte em blob:",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": ["photo", "music"]
            },
            "description": "photo (default) | music"
          },
          {
            "name": "thumb",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "1 = miniatura 320px (só p/ kind=photo)"
          }
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "image/webp (X-MOJ-Photo: placeholder) ou audio/mpeg (X-MOJ-Music: placeholder)"
          }
        }
      }
    },
    "/contest/animeitor/placeholder": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Estado do padrão do contest (foto no topo, música em .music). Leitura também para a sede (.cstaff/.staff)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{custom, bytes, mtime, music:{custom, bytes, mtime}}"
          },
          "403": {
            "description": "animeitor_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Troca o padrão do contest (file_b64; kind=photo default | kind=music, mp3 máx 15MB) ou volta ao de fábrica (action:reset, respeitando o kind). Só .animeitor/admin — o padrão é do contest inteiro",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "kind": { "type": "string", "enum": ["photo", "music"] },
                  "file_b64": { "type": "string" },
                  "action": { "type": "string", "enum": ["reset"] }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{custom, bytes, mtime, music:{custom, bytes, mtime}}"
          },
          "403": {
            "description": "animeitor_required"
          },
          "422": {
            "description": "kind_invalid"
          }
        }
      }
    },
    "/contest/animeitor/webcast": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Chaves do webcast (a chave vai em claro: é o que se copia para o Animeitor)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{keys:[{id,key,view,label,created_by,created_at,revoked_at,fetches,last_at,last_ip}], views:[{id,name}], url_path, contest}"
          },
          "403": {
            "description": "animeitor_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Cria/revoga chave do webcast (view = visão de coorte servida)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["action"],
                "properties": {
                  "action": { "type": "string", "enum": ["create", "revoke"] },
                  "view": { "type": "string" },
                  "label": { "type": "string" },
                  "id": { "type": "string" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{created:true,key,view} ou {revoked:true,id}"
          },
          "422": {
            "description": "view_invalid"
          },
          "403": {
            "description": "animeitor_required"
          }
        }
      }
    },
    "/contest/admin/teams": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Times por-usuário: set{login:{fullname?,univ_short?,univ_full?,country?,region?}} (fullname = nome do time, campo único; '' apaga os de .team) ou action:materialize (aplica teams-meta/regions aos campos VAZIOS); USERS_FROM → 409",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved,skipped[]} | {materialized,filled}"
          },
          "409": {
            "description": "shared_users (contest com usuários compartilhados)"
          }
        }
      }
    },
    "/contest/admin/team-assets": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Foto/brasão do time: {kind:photo|logo, filename:'<login>.<ext>', file_b64} (1 por POST; login case-insensitive) ou {action:delete,kind,login}; máx 8MB; USERS_FROM → 409",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved:true,login,kind} | {deleted:true}"
          },
          "404": {
            "description": "nenhum usuário casa com o nome do arquivo"
          }
        }
      }
    },
    "/contest/admin/config": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Lê config do contest (cores/regiões/teams-meta/basic)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{name,mode,letters,colors,regions,teams_meta,basic}"
          },
          "403": {
            "description": "não-admin do contest"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Grava config do contest",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "colors": {
                    "type": "object"
                  },
                  "regions": {
                    "type": "array"
                  },
                  "teams_meta": {
                    "type": "array"
                  },
                  "basic": {
                    "type": "object"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{saved:true}"
          }
        }
      }
    },
    "/contest/admin/users": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Usuários do contest (sem senha)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{users:[{login,fullname,email,admin}],shared}"
          }
        }
      }
    },
    "/contest/admin/user-add": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Adiciona/reseta usuário do contest (fullname = nome do time; univ_short/univ_full/country/region opcionais mesclam no .team)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  },
                  "fullname": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "univ_short": {
                    "type": "string"
                  },
                  "univ_full": {
                    "type": "string"
                  },
                  "country": {
                    "type": "string"
                  },
                  "region": {
                    "type": "string"
                  }
                },
                "required": [
                  "login"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{user:{login,password,fullname,email}}"
          }
        }
      }
    },
    "/contest/admin/users-bulk": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Carga de usuários em lote (skip|update; conta privilegiada existente nunca é resetada; fullname = nome do time; univ_short/univ_full/country/region opcionais gravam o .team — carga única de credenciais+time)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "users": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "login": {
                          "type": "string"
                        },
                        "password": {
                          "type": "string"
                        },
                        "fullname": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string"
                        },
                        "univ_short": {
                          "type": "string"
                        },
                        "univ_full": {
                          "type": "string"
                        },
                        "country": {
                          "type": "string"
                        },
                        "region": {
                          "type": "string"
                        }
                      },
                      "required": [
                        "login"
                      ]
                    }
                  },
                  "on_existing": {
                    "type": "string",
                    "enum": [
                      "skip",
                      "update"
                    ]
                  }
                },
                "required": [
                  "users"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{created[],updated[],skipped:[{login,reason}],counts}"
          }
        }
      }
    },
    "/contest/admin/user-remove": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Remove usuário do contest",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": {
                    "type": "string"
                  }
                },
                "required": [
                  "login"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{removed:true}"
          },
          "409": {
            "description": "não pode remover a si mesmo"
          }
        }
      }
    },
    "/contest/admin/site-lock": {
      "get": {
        "tags": ["contest"],
        "summary": "Trava de sede por IP (admin/chefe): IPs presos a este contest, bloqueios e reivindicações auditadas",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "{enabled, grace, claims[], blocks[], claims_audit[]}"}, "403": {"description": "admin_required"}}
      },
      "post": {
        "tags": ["contest"],
        "summary": "Trava de sede: {action:set,enabled,grace?} | {action:release,ip} | {action:claim-seen} (só admin; auditado)",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "{saved|released|claimed}"}, "403": {"description": "admin_required"}, "409": {"description": "site_lock_off"}, "422": {"description": "grace_invalid"}}
      }
    },
    "/contest/admin/anomalies": {
      "get": {
        "tags": ["contest"],
        "summary": "Anomalias de uso de máquina durante a prova (admin ou juiz-chefe): 2 sessões vivas, máquina compartilhada, submissão de outra máquina, UA fora da sede, sede sem máquina por time, trocas, trilha da sessão única — só com gate de UA ligado",
        "parameters": [
          {"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}},
          {"name": "round", "in": "query", "required": false, "schema": {"type": "string"}}
        ],
        "responses": {
          "200": {"description": "{gate:{mode,single_session,active}, round, window, computed_at, session_classes:{competitors,staff,privileged}, counts, anomalies[], events[], teams[], machines[], sites[], nutella_at} — ver docs/API.md"},
          "400": {"description": "round_invalid"},
          "403": {"description": "admin_required"},
          "404": {"description": "round_notfound"}
        }
      }
    },
    "/contest/admin/sessions": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Sessões ativas + alerta UA/IP (com mkey = chave de máquina); semeia o índice de sessões por login",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{sessions,alerts}"
          }
        }
      }
    },
    "/contest/admin/audit-log": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Feed unificado de auditoria (admin + logins + submissões + veredictos)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{events:[{time,who,kind,action,details}],count} — filtros: since,action,user,limit"
          }
        }
      }
    },
    "/contest/admin/dashboard": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Situação ao vivo: juízes, fila e métricas de submissão/espera",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{judges:{online,busy,total,queue_depth,assigned,pool,list}, submissions:{total,pending,pending_list,max_wait_s,response:{avg_s,max_s,p50_s,p95_s},timeline}} (pool = hostnames de CONTEST_JUDGES; [] = sem pool). Inclui `routing` (shards do escritor): {shards, workers:[{shard, alive_age_s, in_submit, in_results, in_other}], orphans, queue_depth, assigned, delivered_5m}"
          }
        }
      }
    },
    "/contest/admin/access-log": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Log de acessos do contest",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{entries,alerts}"
          }
        }
      }
    },
    "/contest/admin/settings": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Configurações do contest (inclui mode read-only, tz = fuso IANA da prova (CONTEST_TZ; vazio = MOJ_TZ da instalação), judges[] = pool de juízes do contest, e a penalidade ICPC: penalty_minutes, penalty_verdicts; show_log é o valor EFETIVO — em modo icpc, SHOWLOG ausente = false)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "settings"
          }
        },
        "description": "review_judges (1..5, default 2): quantos juízes humanos validam cada veredicto na correção manual. balloons_during_freeze (bool, default false): entregar balão com o placar CONGELADO. O default RETÉM — acerto feito no freeze não vira tarefa de entrega e não é entregue depois; ligar libera retroativamente os retidos (resposta traz balloons_released) e o GET traz balloons_frozen com quantos estão suprimidos."
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Atualiza configurações (auditado; tz = fuso IANA da prova, 422 tz_invalid se não existir no zoneinfo e null/vazio volta ao padrão da instalação; judges[] = pool de juízes — hostnames do registro, vazio remove: escalonador ESTRITO, pool offline segura a fila; penalty_minutes int default 20, penalty_verdicts array de wa/tle/mle/rte/ce — editar em prova recomputa o placar; show_log:true grava SHOWLOG=1 explícito — religa o log mesmo em modo icpc)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{saved,changed}"
          }
        },
        "description": "review_judges (1..5, default 2): quantos juízes humanos validam cada veredicto na correção manual. balloons_during_freeze (bool, default false): entregar balão com o placar CONGELADO. O default RETÉM — acerto feito no freeze não vira tarefa de entrega e não é entregue depois; ligar libera retroativamente os retidos (resposta traz balloons_released) e o GET traz balloons_frozen com quantos estão suprimidos."
      }
    },
    "/contest/admin/seed": {
      "post": {
        "tags": ["contest-admin"],
        "summary": "Povoa um contest de DEMONSTRAÇÃO com times e submissões sintéticas",
        "description": "Só em contest criado com demo:true (DEMO=1 no conf); em qualquer outro responde 403 demo_required. Existe para quem desenvolve cliente de placar (Animeitor) ter um placar de verdade sem uma prova acontecendo. Escreve pelos MESMOS escritores do veredicto real (user_history_append + metrics_recompute + score/build.sh), é determinístico pelo seed, e freeze_minute grava o FREEZE_TIME (é o que faz placar.txt diferir de placar-full.txt).",
        "security": [{"bearerAuth": []}],
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "requestBody": {"content": {"application/json": {"schema": {"type": "object", "properties": {
          "teams": {"type": "integer", "minimum": 1, "maximum": 500, "default": 20},
          "submissions": {"type": "integer", "minimum": 0, "maximum": 20000, "default": 200},
          "seed": {"type": "integer", "default": 1},
          "freeze_minute": {"type": "integer"},
          "window_minutes": {"type": "integer"},
          "password": {"type": "string", "default": "demo1234"},
          "verdicts": {"type": "object", "description": "pesos: accepted, wrong, tle, rte, ce, pending"}
        }}}}},
        "responses": {
          "200": {"description": "{teams,teams_created,submissions,seed,freeze_time,window_minutes,password,board_lines,by_verdict{}}"},
          "403": {"description": "não é admin do contest, ou o contest não é DEMO (demo_required)"},
          "422": {"description": "parâmetro fora da faixa, contest sem problemas ou janela inválida"}
        }
      }
    },
    "/contest/admin/bank": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Busca p/ adicionar problemas: banco público + privados do DONO do contest (mesmo sujeito do gate de add; privados primeiro). ?meta=1 devolve tags+coleções do banco público",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "q",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "collection",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "meta",
            "in": "query",
            "schema": {
              "type": "integer",
              "enum": [
                1
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[{id,title,tags,collections,access,private,has_statement}],total,mine,shared} ou {tags,collections}"
          }
        }
      }
    },
    "/contest/admin/draw": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Sorteio no banco público (contrato do /treino/contest-create/draw: tag+coleção+dificuldade em AND, seed reproduzível)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "tags",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "collections",
            "in": "query",
            "description": "array JSON url-encoded de nomes de coleção",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "count",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "match",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "any",
                "all"
              ]
            }
          },
          {
            "name": "difficulty",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "any",
                "easy",
                "medium",
                "hard",
                "known"
              ]
            }
          },
          {
            "name": "seed",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{problems[],candidates,drawn,seed,collections}"
          }
        }
      }
    },
    "/contest/admin/problems": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Lista problemas (inclui languages e judges por problema)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{problems}"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "add|remove|reorder|rename|langs|judges|statement (langs -> problem-langs.json; judges -> problem-judges.json, pool por problema, vazio herda o do contest; add de privado: só se o dono do contest é dono/colaborador — senão 404) — rename {letter,name?,new_letter?} troca o identificador (migra a cor do balão); reorder preserva letra customizada (só re-letra sequência automática)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{problems}"
          },
          "404": {
            "description": "problema privado sem acesso (não vaza existência)"
          }
        }
      }
    },
    "/contest/statistics": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Estatísticas agregadas (admin/judge/mon)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{totals,problems,languages,verdicts,timeline, by_region:{<sede>:{...mesmo shape}}, by_country:{<flag>:{...mesmo shape}}}. totals inclui enrolled (inscritos), users (submeteram) e absent; bucket 0 do problems_solved_dist inclui ausentes; fatia de regions.json com view:true sai marcada (recorte sobreposto)"
          }
        }
      }
    },
    "/contest/clarifications": {
      "get": {
        "tags": [
          "contest"
        ],
        "summary": "Clarifications (role-aware)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{clarifications,can_answer}"
          }
        }
      }
    },
    "/contest/clarification-ask": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Perguntar",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{asked,id}"
          }
        }
      }
    },
    "/contest/clarification-claim": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Reservar uma clarification p/ responder (admin/judge/mon; TTL 5min)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{claimed_by,expires_at} — body {id,action:claim|release}; 409 clar_claimed/already_answered"
          }
        }
      }
    },
    "/contest/clarification-broadcast": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Aviso oficial: Q+A público com autor oculto (admin/judge/mon)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{broadcast,id} — body {problem?,question,answer}"
          }
        }
      }
    },
    "/contest/clarification-answer": {
      "post": {
        "tags": [
          "contest"
        ],
        "summary": "Responder (admin/judge/mon; já respondida só chief/admin edita)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{answered}"
          }
        }
      }
    },
    "/contest/admin/rounds": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Rodadas + checklist de promocao (admin ou juiz-chefe)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{active, rounds[], next, promote_ready:{ok,blockers}}"
          },
          "403": {
            "description": "admin_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "add | set | problems | remove | publish | promote (so admin)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "add",
                      "set",
                      "problems",
                      "remove",
                      "publish",
                      "promote"
                    ]
                  },
                  "slug": {
                    "type": "string"
                  },
                  "new_slug": {
                    "type": "string",
                    "description": "renomeia a rodada (action=set; arquivada nao muda)"
                  },
                  "name": {
                    "type": "string"
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "warmup",
                      "official",
                      "extra"
                    ]
                  },
                  "start": {
                    "type": "integer"
                  },
                  "end": {
                    "type": "integer"
                  },
                  "freeze": {
                    "type": "integer"
                  },
                  "problems": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "on": {
                    "type": "boolean"
                  },
                  "to": {
                    "type": "string"
                  },
                  "force": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          },
          "403": {
            "description": "admin_required"
          },
          "409": {
            "description": "not_ready (com blockers) | slug_taken | not_pending | round_archived | archive_exists"
          },
          "422": {
            "description": "slug_invalid | epoch_invalid | end_before_start | freeze_outside | kind_invalid"
          },
          "429": {
            "description": "busy (promocao em andamento)"
          }
        }
      }
    },
    "/contest/admin/round-archive": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "tar.gz do arquivo CRU da rodada (com codigo-fonte) — so admin",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "round",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "application/gzip"
          },
          "404": {
            "description": "not_archived"
          }
        }
      }
    },
    "/contest/admin/ua-gate": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Gate de navegador por sede (admin ou juiz-chefe); ?login= faz dry-run",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "login",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{gate, legacy, regions, check}"
          },
          "403": {
            "description": "admin_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "set | check",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "set",
                      "check"
                    ]
                  },
                  "mode": {
                    "type": "string",
                    "enum": [
                      "enforce",
                      "off"
                    ]
                  },
                  "from_login": {
                    "type": "object",
                    "description": "{regex, expect} — expect usa \\1 para a captura",
                    "properties": {
                      "regex": {
                        "type": "string"
                      },
                      "expect": {
                        "type": "string"
                      }
                    }
                  },
                  "by_region": {
                    "type": "object",
                    "description": "{\"Sede\": \"substring\"}"
                  },
                  "by_regex": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "exempt": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "fallback": {
                    "type": "string"
                  },
                  "login": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{saved, gate} ou {check}"
          },
          "403": {
            "description": "admin_required"
          },
          "422": {
            "description": "mode_invalid | regex_invalid | rule_long | too_many"
          }
        }
      }
    },
    "/contest/admin/machines": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Mapa de maquinas da rodada: time x IP x User-Agent",
        "description": "Agregado do var/access.log recortado pela janela da rodada. changed = mudou de maquina em relacao a ultima rodada arquivada. Só leitura: sede via /contest/admin/teams, gate de UA via /contest/admin/settings.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "round",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{window, by_login[], by_ip[], uas[], ua_suggestion, totals}"
          },
          "403": {
            "description": "admin_required"
          }
        }
      }
    },
    "/contest/admin/registrations": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Inscrições do contest (roster + janela): times, individuais (com univ/flag/ai), convites pendentes — cada um com invited_meta (desde quando espera, se tem Telegram vinculado, quando foi avisado) — e totais",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "contest",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{enabled,remind,window:{…,official_start,official_round},round_kind,gate_active,team_max,teams_allowed,teams:[{…,invited,invited_meta:[{login,at,dm_at,warned_at,tg}]}],individuals,totals:{…,invites,invites_no_tg}}"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "enable | disable | window {open,close,late_minutes,team_max,teams,remind} (campo ausente não mexe; null APAGA a chave — close:null volta a herdar o início da prova) | add | rm | team-add | team-rm | team-meta {team,univ?,ai?,flag?} | individual-meta {login,univ?,ai?,flag?} | materialize | invite-remind {team,login} | invite-remind-all (o mojinho manda DM ao convidado com o link de aceitar/recusar; remind=false desliga o aviso automático da véspera)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "action": {
                    "type": "string"
                  }
                },
                "required": [
                  "action"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "estado completo das inscrições (mesmo shape do GET); no invite-remind* vem junto remind_result:{sent,skipped:[logins sem Telegram]}"
          },
          "403": {
            "description": "admin_required"
          },
          "404": {
            "description": "no_invite (não há convite pendente desse time p/ esse login)"
          },
          "409": {
            "description": "registration_closed (janela fechada: o convite não pode mais ser aceito) | no_telegram (o convidado não tem Telegram vinculado)"
          }
        }
      }
    },
    "/contest/admin/cohorts": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Coortes de placar: oficiais x convidados (admin ou juiz-chefe)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{cohorts, results_released, views, counts, by_regex_only}"
          },
          "403": {
            "description": "admin_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "add | set | rm | assign | materialize | release (so admin)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "add",
                      "set",
                      "rm",
                      "assign",
                      "materialize",
                      "release"
                    ]
                  },
                  "id": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "regex": {
                    "type": "string",
                    "description": "casa o login; tem de compilar"
                  },
                  "public": {
                    "type": "boolean"
                  },
                  "unranked": {
                    "type": "boolean",
                    "description": "aparece sem consumir posicao oficial"
                  },
                  "default": {
                    "type": "boolean"
                  },
                  "sees": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "login": {
                    "type": "string"
                  },
                  "cohort": {
                    "type": "string"
                  },
                  "on": {
                    "type": "boolean",
                    "description": "release: liberar os resultados"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          },
          "403": {
            "description": "admin_required"
          },
          "409": {
            "description": "id_taken | is_default | cohort_in_use"
          },
          "422": {
            "description": "id_invalid | regex_invalid | too_many"
          }
        }
      }
    },
    "/contest/admin/docs": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Documentos da prova: estado, textos, capa e PDF PRONTO enviado (admin ou juiz-chefe); pt/en/es",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{docs, config, templates:{info_sheet_pt,info_sheet_en,cover_pt,cover_en}, cover_uploaded:{pt,en}, problems:[{letter,name,has_pdf,has_html}]}"
          },
          "403": {
            "description": "admin_required"
          }
        }
      },
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "config | cover | upload (PDF pronto: vence o gerado; remove_upload volta) | generate | publish | unpublish",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "action"
                ],
                "properties": {
                  "action": {
                    "type": "string",
                    "enum": [
                      "config",
                      "cover",
                      "generate",
                      "publish",
                      "unpublish"
                    ]
                  },
                  "caderno_version": {
                    "type": "string"
                  },
                  "cover_note": {
                    "type": "string"
                  },
                  "errata": {
                    "type": "string"
                  },
                  "info_sheet_pt": {
                    "type": "string"
                  },
                  "info_sheet_en": {
                    "type": "string"
                  },
                  "cover_pt": {
                    "type": "string"
                  },
                  "cover_en": {
                    "type": "string"
                  },
                  "lang": {
                    "type": "string",
                    "enum": [
                      "pt",
                      "en",
                      "es"
                    ]
                  },
                  "pdf_b64": {
                    "type": "string",
                    "description": "capa em PDF (action=cover)"
                  },
                  "remove": {
                    "type": "boolean",
                    "description": "remove a capa enviada (action=cover)"
                  },
                  "types": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "langs": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "type": {
                    "type": "string",
                    "enum": [
                      "info-sheet",
                      "contest",
                      "times",
                      "editorial"
                    ]
                  },
                  "news": {
                    "type": "boolean",
                    "description": "cria a noticia com o PDF anexo (action=publish)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ok"
          },
          "400": {
            "description": "action_invalid | type_invalid | lang_invalid | pdf_invalid"
          },
          "403": {
            "description": "admin_required"
          },
          "409": {
            "description": "not_generated (publicar sem gerar)"
          }
        }
      }
    },
    "/contest/admin/news": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Notícias do contest (admin/judge/mon)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{items}"
          }
        }
      }
    },
    "/contest/admin/jplag-run": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Dispara jplag (background)",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{started}"
          }
        }
      }
    },
    "/contest/admin/jplag-results": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Status + pares de similaridade",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{status,results:[{problem,lang,pairs:[{a,b,similarity,a_login,a_name,a_univ,b_login,b_name,b_univ}]}]} — a/b = nome sanitizado do arquivo; a_name = nome do time (aditivo; run antigo não tem)"
          }
        }
      }
    },
    "/contest/admin/jplag-match": {
      "get": {
        "tags": [
          "contest-admin"
        ],
        "summary": "HTML lado-a-lado",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "text/html"
          }
        }
      }
    },
    "/contest/admin/logout-all": {
      "get": {
        "tags": ["contest"],
        "summary": "Sair em massa + trava de login (admin): estado do login e sessões por classe",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "{login_enabled, sessions:{competitors,staff,privileged}}"}, "403": {"description": "admin_required"}}
      },
      "post": {
        "tags": ["contest"],
        "summary": "{scope:competitors|staff|all, close_login?, open_login?} — derruba as sessões do escopo (nunca admin/juiz/chefe/monitor/telão) e/ou fecha/reabre o login; auditado",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "{logged_out, scope, competitors, staff, login_enabled}"}, "403": {"description": "admin_required"}, "422": {"description": "scope_invalid | conflict"}}
      }
    },
    "/contest/admin/logout-user": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Encerra sessões de um usuário",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": {
                    "type": "string"
                  }
                },
                "required": [
                  "login"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{logged_out,sessions_removed}"
          }
        }
      }
    },
    "/contest/admin/user-disable": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Desabilita (bloqueia) um usuário",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "login": {
                    "type": "string"
                  }
                },
                "required": [
                  "login"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{disabled}"
          }
        }
      }
    },
    "/contest/admin/user-disqualify": {
      "post": {
        "tags": ["contest-admin"],
        "summary": "Desclassifica o time (some do placar e da estatística; undo reverte)",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["login"], "properties": {"login": {"type": "string"}, "undo": {"type": "boolean"}}}}}},
        "responses": {"200": {"description": "{disqualified, login}. Seta .disqualified=true no account.json: fora do placar (sc_users) e da estatística (stats-gen) — mesma população nas duas telas. Não mexe em senha/sessões."}},
        "security": [{"bearer": []}]
      }
    },
    "/contest/classification": {
      "get": {
        "tags": ["contest"],
        "summary": "Classificação PUBLICADA p/ a próxima fase (chip ↑BR do placar)",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "{stages:[{id,name,venue,when,teams:{login:{via,sede}}}]} — só stages published; vazio sem publicação. Gate de contest secreto igual ao do placar."}}
      }
    },
    "/contest/admin/classify": {
      "post": {
        "tags": ["contest-admin"],
        "summary": "Motor de classificação da próxima fase (preview/apply/publish/add/remove)",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "requestBody": {"content": {"application/json": {"schema": {"type": "object", "required": ["action"], "properties": {"action": {"type": "string", "enum": ["preview","apply","publish","unpublish","add","remove"]}, "stage": {"type": "string"}, "config": {"type": "object"}, "name": {"type": "string"}, "venue": {"type": "string"}, "when": {"type": "string"}, "login": {"type": "string"}, "note": {"type": "string"}}}}}},
        "responses": {"200": {"description": "preview roda o motor sem gravar; apply grava rascunho (promoções comite preservadas); publish/unpublish trocam a visibilidade; add/remove mexem nos times. Auditado. Ver docs/CLASSIFICACAO.md"}},
        "security": [{"bearer": []}]
      },
      "get": {
        "tags": ["contest-admin"],
        "summary": "Estado da classificação (classification.json inteiro)",
        "parameters": [{"name": "contest", "in": "query", "required": true, "schema": {"type": "string"}}],
        "responses": {"200": {"description": "{stages:[...]} — inclui rascunhos/config/notas (admin)"}},
        "security": [{"bearer": []}]
      }
    },
    "/contest/admin/users-set-password": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Senha única para todos os não-privilegiados",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "password": {
                    "type": "string"
                  },
                  "include_disabled": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "password"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{updated,count}"
          }
        }
      }
    },
    "/contest/admin/logout-mismatch": {
      "post": {
        "tags": [
          "contest-admin"
        ],
        "summary": "Desloga sessões com UA divergente",
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{logged_out,sessions_removed}"
          }
        }
      }
    },
    "/problems/mine": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Meus problemas (dono/autor)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[{id,title,author,owner,collections,public,html,claimed}]}"
          }
        }
      }
    },
    "/problems/shared": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Problemas compartilhados comigo: tudo que posso editar e não é meu — membro da org OU colaborador (não dono)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[…]}"
          }
        }
      }
    },
    "/problems/public": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Problemas públicos (visão de gestão dono/autor)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[…]}"
          }
        }
      }
    },
    "/problems/collection": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Problemas de uma coleção (curso/diretório)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "name",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{problems:[…]}"
          }
        }
      }
    },
    "/problems/collections": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Coleções com contagem total/pública",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{collections:[{name,count,public}]}"
          }
        }
      }
    },
    "/problems/get": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Detalhe do problema (índice + validação)",
        "description": "Devolve time_limits = o EFETIVO (TLOVERRIDE do conf do pacote aplicado por tl_store_served), mais time_limits_calibrated (cru dos juízes) e tl_override (o declarado). O TL vem do PACOTE, não do json servível: o json público só existe depois de publicar, e edit/upload não reindexam — antes disso o editor mostrava o calibrado cru em problema privado.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "índice + validation (relatório do portão) + statement_html_b64/tags/time_limits. Inclui `languages` (whitelist de submissão; vazio = todas as padrão)"
          },
          "404": {
            "description": "não encontrado"
          }
        }
      }
    },
    "/problems/validation": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Último relatório de validação (portão de qualidade)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{checks:[{name,ok,detail}],html_built,render_warnings,ok} — checks incluem secao_entrada/secao_saida; render_warnings pode trazer 'exemplo-no-texto?'"
          }
        }
      }
    },
    "/problems/status": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Painel de status dos problemas do login (dono + colaborador + membro da org)",
        "description": "Agrega validação, calibração, time_limits e 'precisa recalibrar' dos problemas de que o login é dono OU colaborador. FRONTEIRA de acesso: problema privado de terceiro NÃO aparece (owners_visible). Sem hash de pacote por request — stale vem do checksum carimbado no índice (≤30 min de atraso); use /problems/tl p/ o valor exato ao vivo. time_limits é o EFETIVO (o TLOVERRIDE vem carimbado no índice de donos, então o Painel não abre pacote); time_limits_calibrated e tl_override vêm ao lado.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{total,counts:{validated,validation_error,unvalidated,calibrated,uncalibrated,being_calibrated,needs_recalibration,good_sol_no_tl,public_unvalidated,needs_review,errors},calibrating_ids,attention_ids,problems:[{id,title,owner,author,public,collaborators,validated,calibrated,being_calibrated,stale,needs_recalibration,good_sol_no_tl,good_sol_missing_langs,public_unvalidated,error,needs_review,review_reasons,time_limits,updated_at,validated_at}]} — good_sol_no_tl = solução good sem TL (linguagem que falhou em todos os juízes); needs_review = precisa revisão"
          }
        }
      }
    },
    "/problems/tl": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Time limits AO VIVO + staleness exata de 1 problema (com o PORQUÊ da recalibração)",
        "description": "Recomputa o checksum do pacote agora (1 problema) e devolve o TL servível + stale/needs_recalibration exatos. time_limits = o EFETIVO: com TLOVERRIDE no conf do pacote, override[lang] // override[default] // calibrado[lang]; time_limits_calibrated = o cru dos juízes; tl_override = o declarado no conf ({} sem override). Quando needs_recalibration, explica o motivo: reason (checksum velho→novo), changes = commits desde a calibração que tocaram os caminhos cobertos pelo tl-checksum (conf, tests/input, sols/good, scripts; [{sha,at,author,subject}], ≤20) e changed_files (≤30). Acesso: dono/colaborador OU público (require_problem_view; 404 senão). Versão não-admin do /ops/problemtl.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{problem,checksum,time_limits,time_limits_calibrated,tl_override,calibrated_checksum,hosts,updated_at,calibrated_at,calibrated,being_calibrated,stale,needs_recalibration} + quando needs_recalibration: {reason,changes,changed_files}. being_calibrated=true enquanto há calibração pendente/em execução p/ o problema."
          },
          "404": {
            "description": "privado e o login não é dono/colaborador (not_found)"
          }
        }
      }
    },
    "/problems/recalibrate-stale": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Recalibra em LOTE tudo que \"precisa recalibrar\" no painel do login",
        "description": "Recomputa a lista \"calibrado E checksum divergente\" com a MESMA fronteira do /problems/status (owners_visible, estreitado a dono/colaborador/membro-da-org) e enfileira cada uma via cal_request (idempotente + serializado por-problema no claim do juiz — lote é seguro por construção). Corpo opcional {ids:[...]} restringe ao subconjunto, sempre INTERSECTADO com o conjunto autorizado — ids de fora são ignorados, nunca enfileirados. Web: botão \"⚙ Recalibrar todos (N)\" no Painel; CLI: moj calibrate --all-stale.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "subconjunto opcional; intersectado com os stale autorizados do login"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{action:\"recalibrate-stale\", count, queued:[{id,reqid}]}"
          }
        }
      }
    },
    "/problems/my-stats": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Análise dos problemas do login, agregada em toda a plataforma",
        "description": "Panorama (cache precomputado) dos problemas que o login é dono/colaborador, somando submissões de TODOS os contests (treino + turmas). Reconcilia o namespace do history (problemas-apc# etc.) com o índice de donos via `collections`. FRONTEIRA: só os problemas do login. PRIVACIDADE: só agregados — sem logins, sem nomes de contests (só contests_count).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{totals:{owned,with_activity,attempts,accepts,solvers}, overall_verdicts:[{verdict,count}], overall_languages:[{lang,submissions,accepted}], most_popular:{id,title,attempts}, problems:[{id,title,attempts,accepts,wrong,acceptance_rate,distinct_users,solvers,contests_count,verdicts,languages,first,last}]}"
          }
        }
      }
    },
    "/problems/calib": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Resumo de calibração por juiz (TL, log, reports)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{id,checksum,good_langs,missing_langs,tl_override,time_limits,time_limits_calibrated,hosts:[{host,tl,missing,at,log,reports,sols}]} — missing_langs = linguagem good sem TL em NENHUM host (solução good falhou em todas as máquinas); hosts[].missing = faltantes naquele juiz; sols = calibração POR EXTENSO estruturada [{file,lang,category:good|pass|slow|wrong,verdict,tests:[{name,code,time,tl}]}] (mesmo formato do vetor tests da submissão normal; [] = juiz antigo); tl_override = TLOVERRIDE do conf do pacote ({} sem override); hosts[].tl e sols[].tests[].tl são a MEDIÇÃO da calibração (que roda com MOJ_CALIBRATING=1 e ignora o override de propósito) — time_limits é o efetivo, para a tela poder avisar que o julgamento usa outro número"
          }
        }
      }
    },
    "/problems/calib-report": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "report.html de uma solução naquele juiz (calibração)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "host",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "text/html do report da solução",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/problems/test-run": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Roda UMA solução avulsa NO JUIZ (autoria; fora do history)",
        "description": "Job real na fila (banda lista-privada, contest sentinela _testrun): mesma jaula e mesmo TL da submissão de aluno, sem tocar history/placar de ninguém. Gate: membro da org (require_problem_edit; 404). Teto SUBMIT_MAX_KB (413 source_too_large); linguagens = PLATAFORMA ∪ languages do pacote (400 lang_not_allowed); máx 3 runs queued por login (429 testrun_busy). Auditado (test-run). Registro em run/testrun/ com TTL de 7 dias.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "id",
                  "code_b64"
                ],
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "filename": {
                    "type": "string"
                  },
                  "code_b64": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{run:<32hex>, status:\"queued\"}"
          }
        }
      },
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Polling de um test-run",
        "description": "{run,problem_id,filename,lang,status:queued|done,requested_at} e, quando done, +{verdict,verdict_canon,score,correct,total_tests,duration_s,tl_used,tests:[{name,code,time,tl}],finished_at,report:bool} — o vetor tests é o MESMO da submissão normal. Gate pelo problema DO REGISTRO (membro da org; 404).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{32}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "registro do test-run"
          }
        }
      }
    },
    "/problems/test-run-report": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "report.html de um test-run",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "run",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-f0-9]{32}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "text/html do report (404 enquanto julga/expirado). Mesmo gate do registro.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/problems/judges": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Juízes do registro (p/ calibração direcionada)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{judges:[{host,cpu,langs,online}]}"
          }
        }
      }
    },
    "/problems/validate": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Portão de qualidade: valida + indexa + pede calibração (NÃO publica)",
        "description": "Roda o validador estático (HTML compila + seções `## Entrada`/`## Saída` + exemplos pareados) + gera o índice, e pede calibração a um juiz (roda as `good`, reporta o TL). **NÃO altera o flag `public`**: problema privado continua privado — publicar é `/problems/set-public`, que é quem checa a trava `public_allowed` da ORG. Relatório em `/problems/validation`.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  }
                },
                "required": [
                  "id"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{reqid,...}"
          }
        }
      }
    },
    "/problems/publish": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "DEPRECADO: alias de /problems/validate",
        "description": "Alias deprecado de `/problems/validate` (o nome fazia parecer que validar publicava).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  }
                },
                "required": [
                  "id"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{reqid,...}"
          }
        },
        "deprecated": true
      }
    },
    "/problems/request-calibration": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Enfileira calibração (juiz roda calibreitor.sh, reporta TL). IDEMPOTENTE: pedido repetido devolve o reqid existente com status already_queued",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "hosts": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Calibração direcionada a juízes específicos (opcional)."
                  }
                },
                "required": [
                  "id"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{action,id, status:queued|already_queued, reqid} (sem hosts) ou {action,id,status,hosts:[{host,cmdid,status:queued|already_queued}]} (direcionada)"
          }
        }
      }
    },
    "/problems/repos": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Diretórios do autor (dono/colaborador)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "responses": {
          "200": {
            "description": "{repos:[{repo,owner,collaborators,collections,mine}]}"
          }
        }
      }
    },
    "/problems/repo-create": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Cria diretório (org no namespace do login)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "repo": {
                    "type": "string"
                  },
                  "collections": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                },
                "required": [
                  "repo"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{repo,owner}"
          }
        }
      }
    },
    "/problems/test": {
      "get": {
        "tags": ["problems"],
        "summary": "Conteúdo de UM teste (par do tests=meta do /problems/source); só membro da org",
        "security": [{"bearerAuth": []}],
        "parameters": [
          {"name": "id", "in": "query", "required": true, "schema": {"type": "string"}},
          {"name": "name", "in": "query", "required": true, "schema": {"type": "string"}}
        ],
        "responses": {"200": {"description": "{name,input,output}"}, "404": {"description": "not_found | test_notfound"}}
      }
    },
    "/problems/source": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Source do pacote inteiro (só dono/colaborador)",
        "description": "Source completo: enunciado + testes OCULTOS + SOLUÇÕES + editorial. Acesso garantido NA API: só dono/colaborador; não-autorizado recebe 404 (sem read-only, sem atalho de .admin). Inclui title, collections, languages (ids de submissão permitidos, [] = todas), examples[].explanation, sols por categoria, score, score_text (tests/score cru), editorial_md, scripts (caminhos, árvore do editor) e scripts_files (round-trip da correção especial: [{path,content_b64,exec}|{path,symlink}]). docs_files = round-trip das imagens de docs/ ([{name,content_b64}]); examples[].explanation vive em docs/notes/<sample>.md (legado sample-notes.json só leitura).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "source + {editable}",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/ProblemFields"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "editable": {
                          "type": "boolean"
                        },
                        "scripts": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          },
                          "readOnly": true,
                          "description": "Caminhos relativos de scripts/ (correção especial) presentes no pacote — SÓ leitura/exibição (não escrito por create/edit). Ex.: [\"compare.sh\",\"c/compile.sh\"]."
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/problems/preview": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Pré-visualização HTML do enunciado",
        "description": "Usa o renderizador único (render-statement.sh) — idêntico ao HTML servido ao aluno. Injeta o título (h1) e os exemplos (cada um com explanation opcional). Aceita id (semeia as imagens de docs/ do pacote no render; exige permissão de edição) e images:[{name,content_b64}] p/ figuras ainda não enviadas.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "enunciado_md": {
                    "type": "string"
                  },
                  "enunciado_format": {
                    "type": "string",
                    "enum": [
                      "md",
                      "org",
                      "tex"
                    ]
                  },
                  "title": {
                    "type": "string"
                  },
                  "examples": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/ProblemExample"
                    }
                  }
                },
                "required": [
                  "enunciado_md"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{success:true, html_b64}"
          }
        }
      }
    },
    "/problems/download": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Baixa o pacote .tar.gz (inclui soluções → membro da org); com sha, a versão daquele commit (git archive)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sha",
            "in": "query",
            "description": "commit (hex 7-40) — baixa a versão histórica; ausente = worktree atual",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "stream binário .tar.gz",
            "content": {
              "application/gzip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        }
      }
    },
    "/problems/upload": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Sobe um pacote (.tar/.gz/.bz2/.zst/.zip) e substitui o conteúdo; do .moj-meta.json do tar lê display_title/collections/languages (ausente=preserva); tags ausente no tar = preservadas",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "repo": {
                    "type": "string"
                  },
                  "prob": {
                    "type": "string"
                  },
                  "tar_b64": {
                    "type": "string"
                  }
                },
                "required": [
                  "tar_b64"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id,sha}"
          }
        }
      }
    },
    "/problems/export": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Exporta como pacote ICPC/Kattis (.tar.gz)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "stream binário .tar.gz (problem.yaml+statement+data+submissions)",
            "content": {
              "application/gzip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        }
      }
    },
    "/problems/import": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Importa pacote ICPC/Kattis → problema MOJ julgável",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "repo": {
                    "type": "string"
                  },
                  "prob": {
                    "type": "string"
                  },
                  "tar_b64": {
                    "type": "string"
                  }
                },
                "required": [
                  "repo",
                  "tar_b64"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id,sha}"
          }
        }
      }
    },
    "/problems/create": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Cria problema novo (commit+push)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/ProblemFields"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "repo": {
                        "type": "string"
                      },
                      "prob": {
                        "type": "string"
                      }
                    },
                    "required": [
                      "repo",
                      "prob"
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id,sha}"
          }
        },
        "description": "docs_files = round-trip das imagens de docs/ ([{name,content_b64}]); examples[].explanation vive em docs/notes/<sample>.md (legado sample-notes.json só leitura)."
      }
    },
    "/problems/edit": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Edita problema (só os campos presentes; commit+push autorado). Aceita languages (ids de submissão permitidos; ausente = não toca, [] = todas), scripts_files (SUBSTITUI scripts/ inteiro: paths validados sem '..', exec vira +x, symlink confinado a scripts/; ausente = não toca) e score_text (tests/score verbatim; '' remove)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/ProblemFields"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      }
                    },
                    "required": [
                      "id"
                    ]
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id,sha}"
          }
        },
        "description": "docs_files = round-trip das imagens de docs/ ([{name,content_b64}]); examples[].explanation vive em docs/notes/<sample>.md (legado sample-notes.json só leitura)."
      }
    },
    "/problems/history": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Histórico git do problema (membro da org); com sha, o git show -p do commit",
        "description": "Cada save/upload é um commit autorado pelo login. Sem sha: lista {sha,at,author,subject,files,insertions,deletions} (limit<=200, default 50). Com sha (hex 7-40): {sha,at,author,subject,truncated,diff_b64} — diff limitado a 400 KB. Expõe soluções/testes => gate de source (membro da org; 404 senão).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "sha",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{id,commits:[...]} ou {sha,at,author,subject,truncated,diff_b64}"
          }
        }
      }
    },
    "/problems/restore": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Restaura o problema ao estado de um commit antigo como um COMMIT NOVO",
        "description": "Body {id, sha, confirm} — confirm repete o sha. A história nunca é reescrita (dá p/ des-restaurar). O .moj-meta.json (público/coleções/owner) é PRESERVADO — meta antigo não republicaria prova privada. Sem revalidação/recalibração automática (o painel acusa 'precisa recalibrar' pelo checksum). Membro da org.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{action:restore, id, from:<sha>, sha:<novo commit>}"
          }
        }
      }
    },
    "/problems/delete": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "REMOVE um problema (git rm + push) e do treino — destrutivo; confirm tem de repetir o id",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "confirm": {
                    "type": "string",
                    "description": "Confirmação por escrito: tem de ser EXATAMENTE igual a id."
                  }
                },
                "required": [
                  "id",
                  "confirm"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{action:'delete', id}"
          },
          "400": {
            "description": "confirm_mismatch (confirm != id) ou id inválido"
          },
          "403": {
            "description": "sem permissão de escrita"
          }
        }
      }
    },
    "/problems/set-public": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Público on => valida + calibra SÓ SE o pacote mudou (tl-checksum != calibrado servido) e grava o flag; off => sai do treino",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "public": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "id",
                  "public"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id,public,reqid?, calibration:queued|up_to_date|\"\"} — up_to_date = TL vigente já é desta versão do pacote (nada enfileirado)"
          }
        }
      }
    },
    "/problems/set-collections": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Define coleções no .moj-meta.json",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "collections": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                },
                "required": [
                  "id",
                  "collections"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id,collections}"
          }
        }
      }
    },
    "/problems/repo-collaborators": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Lista colaboradores do diretório",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          },
          {
            "name": "repo",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{repo,collaborators}"
          }
        }
      },
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Compartilha o diretório (só o dono gerencia)",
        "description": "Cada login em add precisa EXISTIR no treino e PODER CRIAR PROBLEMAS (cc_can_create) — 422 login_invalid / 404 user_notfound / 403 cannot_create, recusa atômica; remove não valida.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/contest"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "repo": {
                    "type": "string"
                  },
                  "add": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "remove": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                },
                "required": [
                  "repo"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{repo,collaborators}"
          }
        }
      }
    },
    "/problems/move": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Move um problema de rascunho p/ outra org (muda o id; bloqueia se público/em uso)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "id",
                  "to_org"
                ],
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "to_org": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{id, from}"
          },
          "403": {
            "description": "não é membro das orgs"
          },
          "409": {
            "description": "público/em uso, ou nome colide no destino"
          }
        }
      }
    },
    "/orgs/list": {
      "get": {
        "tags": [
          "orgs"
        ],
        "summary": "Orgs de que o login é membro (inclui a implícita)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{orgs:[{name,title,members,admins,public_allowed,implicit,count,public,mine,can_manage}]}"
          }
        }
      }
    },
    "/orgs/get": {
      "get": {
        "tags": [
          "orgs"
        ],
        "summary": "Detalhe de 1 org (404 se não-membro)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{name,title,members,admins,public_allowed,implicit,mine,can_manage}"
          },
          "404": {
            "description": "não-membro ou inexistente"
          }
        }
      }
    },
    "/orgs/create": {
      "post": {
        "tags": [
          "orgs"
        ],
        "summary": "Cria org (criador vira membro+admin; exige cc_can_create)",
        "description": "Cada login de members/admins precisa EXISTIR no treino e PODER CRIAR PROBLEMAS (cc_can_create) — 422/404/403 senão, recusa atômica (a org nem nasce).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "members": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "admins": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "title": {
                    "type": "string"
                  },
                  "public_allowed": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "org criada"
          },
          "403": {
            "description": "sem permissão de criação"
          }
        }
      }
    },
    "/orgs/members": {
      "get": {
        "tags": [
          "orgs"
        ],
        "summary": "Membros/admins da org",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{name,members,admins,mine,can_manage,implicit}"
          }
        }
      },
      "post": {
        "tags": [
          "orgs"
        ],
        "summary": "Gerencia membros/admins (só admin da org)",
        "description": "add/admins_add validam cada login (existe no treino + cc_can_create; 422 login_invalid / 404 user_notfound / 403 cannot_create, recusa atômica); remove/admins_remove não validam (lixo já gravado precisa poder sair).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "add": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "remove": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "admins_add": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "admins_remove": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "idem"
          },
          "403": {
            "description": "não é admin da org"
          }
        }
      }
    },
    "/orgs/set-public-allowed": {
      "post": {
        "tags": [
          "orgs"
        ],
        "summary": "Liga/desliga a trava de público (só admin; implícita 409)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name",
                  "public_allowed"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "public_allowed": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "flag alterada"
          },
          "403": {
            "description": "não é admin da org"
          },
          "409": {
            "description": "org implícita é sempre privada"
          }
        }
      }
    },
    "/orgs/delete": {
      "post": {
        "tags": [
          "orgs"
        ],
        "summary": "Remove uma org VAZIA (só admin; implícita/não-vazia 409)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{action:org-delete, name}"
          },
          "403": {
            "description": "não é admin da org"
          },
          "404": {
            "description": "org não encontrada"
          },
          "409": {
            "description": "org implícita (implicit_org) ou com problemas (org_not_empty)"
          }
        }
      }
    },
    "/problems/collection-create": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Cria coleção (TAG curada; nome é texto livre, pode ter espaços)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{name,owner}"
          },
          "403": {
            "description": "sem permissão de criação"
          }
        }
      }
    },
    "/problems/collection-rename": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Renomeia a coleção (registro na hora; re-tag dos problemas em BACKGROUND; name inexistente + to existente = RETOMADA de bulk interrompido; só dono/admin)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "to": {
                    "type": "string"
                  }
                },
                "required": [
                  "name",
                  "to"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{name,from,retag:\"background\",retag_job,resumed}"
          },
          "403": {
            "description": "não é dono da coleção"
          }
        }
      }
    },
    "/problems/collection-retag-status": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Situação dos jobs de retag de coleção (rename/delete em background): jobs mais novos primeiro; sem finished_at = rodando (done/total = progresso)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "job",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "name",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{jobs:[{id,from,to,by,started_at,total,done,failed,finished_at}]}"
          }
        }
      }
    },
    "/problems/collection-delete": {
      "post": {
        "tags": [
          "problems"
        ],
        "summary": "Exclui a coleção (untag dos problemas em BACKGROUND; o registro sai no FIM — repetir o delete retoma bulk interrompido; só dono/admin)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  }
                },
                "required": [
                  "name"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{name,untag:\"background\",retag_job}"
          },
          "403": {
            "description": "não é dono da coleção"
          }
        }
      }
    },
    "/treino/signup/start": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Inicia cadastro web-first (gera nonce+deep-link)",
        "responses": {
          "200": {
            "description": "{nonce,deep_link,expires_at}"
          },
          "409": {
            "description": "login_taken"
          }
        }
      }
    },
    "/treino/signup/status": {
      "get": {
        "tags": [
          "treino"
        ],
        "summary": "Polling do cadastro (pending|created|already_linked|linked|expired)",
        "parameters": [
          {
            "name": "nonce",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{status,login?} (sem senha)"
          }
        }
      }
    },
    "/treino/signup/verify": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Bot: consome nonce, cria+vincula/linka (bot-token)",
        "responses": {
          "200": {
            "description": "{status,login,password?}"
          },
          "401": {
            "description": "bot_unauth"
          },
          "410": {
            "description": "nonce_expired"
          }
        }
      }
    },
    "/treino/signup/telegram": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Bot: /participar — cria+vincula por telegram_id (bot-token)",
        "responses": {
          "200": {
            "description": "{status,login,password?}"
          }
        }
      }
    },
    "/treino/recover-password": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Bot: recupera senha pelo vínculo Telegram (bot-token)",
        "responses": {
          "200": {
            "description": "{status:ok|not_linked,login?,password?}"
          }
        }
      }
    },
    "/treino/telegram/link-start": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Conta logada gera nonce p/ vincular o próprio Telegram (UI: seção Telegram do perfil)",
        "responses": {
          "200": {
            "description": "{nonce,deep_link,expires_at}"
          }
        }
      }
    },
    "/treino/telegram/unlink": {
      "post": {
        "tags": [
          "treino"
        ],
        "summary": "Desvincula o Telegram do PRÓPRIO login (cota anti conta-descartável: TELEGRAM_CHANGE_LIMIT (1)/ano p/ usuário comum — 403 telegram_limit; .admin livre)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{unlinked:true}"
          },
          "404": {
            "description": "nenhum Telegram vinculado (not_linked)"
          }
        }
      }
    },
    "/ops/alerts": {
      "get": {
        "tags": [
          "ops"
        ],
        "summary": "Bot: avalia incidentes + drena outbox de alertas (bot-token); sweeps de convite e do relatório de quartil",
        "responses": {
          "200": {
            "description": "{items:[{id,text,chats:[chat_id],loud,group}]} — chats vazio + group:true = mensagem só para o grupo"
          },
          "401": {
            "description": "bot_unauth"
          }
        }
      }
    },
    "/ops/relatorio": {
      "post": {
        "tags": [
          "ops"
        ],
        "summary": "Bot: painel de submissões p/ o grupo (comando /relatorio; bot-token; gate .admin pelo telegram_id)",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "telegram_id"
                ],
                "properties": {
                  "telegram_id": {
                    "type": "integer"
                  },
                  "args": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "vazio = relatório do semestre configurado | AAAA-MM-DD (override) | config <ini> <fim> | status"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{html} — painel Telegram HTML: top-10 por submissões no período, treino, usuários ativos, comparações com o ano anterior"
          },
          "400": {
            "description": "bad_json/tgid_invalid/bad_date/bad_args"
          },
          "401": {
            "description": "bot_unauth"
          },
          "403": {
            "description": "admin_required — telegram_id sem conta .admin do treino vinculada"
          },
          "409": {
            "description": "not_configured/not_started"
          },
          "422": {
            "description": "bad_date/bad_range (config)"
          }
        }
      }
    },
    "/problems/script-templates": {
      "get": {
        "tags": [
          "problems"
        ],
        "summary": "Templates de corretor especial (scripts/) p/ o editor web",
        "description": "Lê mojtools/script-templates/<key>/ (criar template = criar uma pasta lá). files[] tem o MESMO shape do campo scripts_files de source/edit: {path,content_b64,exec} ou {path,symlink}. Symlink externo (canônicos do mojtools) vem resolvido como conteúdo; interno (ex.: cpp -> c) vem como symlink. Iniciais: checker-testlib, interativo, interativo-rank, compare-float, ban-funcoes-c.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "templates": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "description": {
                            "type": "string"
                          },
                          "conf_hints": {
                            "type": "string"
                          },
                          "files": {
                            "type": "array",
                            "items": {
                              "type": "object"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/ops/judge-config": {
      "get": {
        "tags": [
          "ops"
        ],
        "summary": "Config fina por juiz (multi-slot): particionamento/reserve/disabled",
        "description": "Admin. Config DESEJADA por host (contests/treino/var/judges-config.json); o heartbeat entrega ao agente quando muda e ele aplica após drenar os jobs. ?host= filtra.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "host",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{success, configs:{<host>:{partition,reserve,disabled,updated_at,by}}}"
          }
        }
      },
      "post": {
        "tags": [
          "ops"
        ],
        "summary": "Define a config de um juiz",
        "description": "Body {host, partition?: off|numa|cpus:<X>, reserve?: int, disabled?: bool}. partition particiona a máquina em SLOTS de cpus disjuntas (o agente corrige N problemas ao mesmo tempo, cada job pinado no seu cpuset); reserve tira as N primeiras cpus dos slots; disabled drena e para de receber trabalho.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{success, action, host, config}"
          }
        }
      }
    },
    "/ops/judge-reset": {
      "post": {
        "tags": [
          "ops"
        ],
        "summary": "RECUPERAÇÃO de um juiz sem SSH: kill (mata os jobs presos) ou restart (kill + agente se re-executa)",
        "description": "Admin. Body {host, action?: kill|restart} (default kill). O comando é entregue no PRÓXIMO heartbeat MESMO com o juiz ocupado/desabilitado: o agente SIGKILL-a o grupo de processos de cada slot (job inteiro: build-and-test+bwrap+solução), reporta judge-error/calib-fail ao servidor (nada espera TTL) e reconcilia a config pendente. restart: idem e o agente se re-executa — o register boot:true re-enfileira o que estava atribuído (nenhum item de fila se perde). CLI: moj judges reset/restart.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{success, action:kill|restart, host, cmdid, status:queued}"
          }
        }
      }
    },
    "/ops/judge-cache": {
      "post": {
        "tags": [
          "ops"
        ],
        "summary": "Limpa o cache local de pacotes de um juiz (comando por-host, entregue no heartbeat)",
        "description": "Admin. Body {host, action?: clearcache} (única ação suportada hoje). Enfileira um comando POR-HOST que o agente pega no próximo heartbeat, quando estiver livre: apaga o $JUDGE_CACHE e se re-registra com inventário vazio. Não bloqueia — o efeito aparece no /judge/list. Use quando um juiz ficou com pacote velho ou corrompido em cache.",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{success, action, host, cmdid, status:queued}"
          }
        }
      }
    },
    "/ops/calib-cancel": {
      "post": {
        "tags": [
          "ops"
        ],
        "summary": "Cancela calibrações de um problema na fila (pendentes + direcionadas não entregues)",
        "description": "Admin. Body {id, inprogress?: false}. Remove da fila as calibrações PENDENTES do problema e os comandos direcionados ainda não entregues. Por padrão NÃO toca as em execução (contadas em inflight — para matá-las use /ops/judge-reset); {inprogress:true} remove também os claims de updates/inprogress. CLI: moj judges cancel <id> [--inprogress].",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "{success, action, id, removed_pending, removed_targeted, removed_inprogress, inflight}"
          }
        }
      }
    },
    "/ops/judge-results": {
      "get": {
        "tags": [
          "ops"
        ],
        "summary": "Relatório de correções por juiz (últimas N + agregado por host)",
        "description": "Admin. Varre run/results (cada correção carrega o host que julgou). ?host= filtra; ?limit= (<=500, default 50).",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "host",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "{success, results:[{id,host,verdict,login,problem_id,lang,duration_s,finalized_at,contest,score}], by_host:{<h>:{total,accepted,judge_errors,avg_duration,last_at}}}"
          }
        }
      }
    },
    "/contest/beacon": {
      "get": {
        "summary": "Beacon de tempo assinado p/ a submissão offline (moj-comp): payload_b64.sig_b64; a CLI re-ancora a cada comando. Piso do carimbo do pacote offline.",
        "tags": [
          "contest"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "contest",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/contest/offline-submit": {
      "post": {
        "summary": "Rota emergencial do moj-comp: pacotes cifrados criados sem rede; cada um é validado (beacon assinado, janela beacon.t≤claimed≤now, janela da prova do aluno, monotonicidade, dedup, whitelist de linguagens do problema) e contabilizado NO horário reivindicado, com flag p/ o organizador (var/offline-log + audit).",
        "tags": [
          "contest"
        ],
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "name": "contest",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "packets": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "pacotes cifrados (JSON {v,wk,ct} como string), máx 50"
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}