MOJ — API v1 (referência) — MOJ docs

MOJ — API v1 (referência)

Base: /api/v1. Roteador único: server/api/v1/router.sh → handlers/<rota>.sh. Aviso de CLI desatualizada (lib/cli-version.sh): toda resposta a uma CLI (UA moj[-tool]/<build>) leva X-Moj-Cli-Status: current|outdated|dev e X-Moj-Cli-Latest: <build> (referência = web/moj.build, o mesmo do moj version; build = <git-short>-<AAAAMMDD>, comparada pela data); CLI ANTIGA (UA curl/* + Bearer, sem marcador — não lê cabeçalho) recebe X-Moj-Cli-Status: legacy e a dica "rode moj update" ANEXADA à error.message. Navegador e curl cru: nada. A CLI avisa no stderr uma vez por dia.

Auth: Authorization: Bearer <token>. Respostas JSON com envelope {success:true, …} ou {success:false, error:{message,code}} + status HTTP correto. Histórico e placar são TXT cru. Horários em EPOCH. IDs validados contra path-traversal.

Auth

Rota Método Auth I/O
/auth/login?contest=<c> POST — body {username,password} → {token,logged_in,username,name,contest,server_utc}. Contest compartilhado (USERS_FROM): senha LOCAL não-vazia é autoritativa (sem cair p/ a fonte); pela fonte, conta de papel (.admin/.judge/.staff/…) só entra se for o SHARED_ADMIN (ou <owner>.admin) ou um SUPERADMIN do treino — os demais recebem a mesma recusa de senha errada, e uma sessão já aberta deles vira 401 (28/09/2026). Contest (≠ treino) inclui o kit da submissão OFFLINE do moj-comp: offline_pubkey_pem (pública RSA-4096 do contest, gerada lazy em contests/<c>/secrets/) e beacon (carimbo de tempo assinado — ver /contest/beacon). server_utc permite à CLI medir o desvio do relógio local.
/auth/status?contest=<c> GET Bearer {logged_in,login,name,contest,is_admin,is_judge,is_staff,is_cstaff,is_chief,is_animeitor,is_mon,has_photo} (.cjudge = juiz-chefe → is_judge:true,is_chief:true; .cstaff = chefe de sede → is_cstaff:true, sem herdar is_staff; is_mon liga o alerta de clarification do .mon). has_photo existe p/ o avatarEl NÃO pedir a foto de quem não tem: o avatar do cabeçalho aparece em toda página e cada 404 desses é um fork de bash (5.712 no dia 24/08/2026, 54% de todos os 404). Mesmo campo em /index/open_training (top_users[] e recent_solved[].user) e em /treino/problem-stats
/auth/logout POST Bearer {logged_out:true} — apaga o arquivo de sessão mesmo se ela já não vale (senão o zumbi ficava p/ sempre no store)

A porta do contest (/auth/login, forçada pela API — o countdown do front é só conveniência): LOGIN_ENABLED=n → 403 login_disabled; antes de LOGIN_START_TIME → 403 login_not_open; com INSCRIÇÃO ligada (contests/<c>/registrations.json existe) quem não está no roster leva 403 not_registered (janela ainda aberta), registration_not_open ou registration_closed — aquecimento INCLUSO (default): só inscrito entra em qualquer rodada. REG_WARMUP_OPEN=y no conf restaura a porta aberta durante rodada warmup (opt-in); nesse caso a promoção da oficial derruba a sessão de quem não se inscreveu (reg_sweep_unregistered) e apaga o diretório vazio. Conta de PAPEL (.admin/.judge/.cjudge/.staff/.cstaff/.mon) nunca é barrada. Alias de TIME: se o login é membro de um time inscrito, a credencial é a DELE mas a sessão é do time — a resposta traz actor (quem digitou) e is_team:true, e /auth/status, /contest/userinfo, o access.log (5ª coluna) e o var/actor-log guardam o ator. Ver lib/registration.sh.

Invariante da sessão: o token só continua valendo enquanto a CONTA existir (users/<login>/account.json do contest da sessão ou, com USERS_FROM, o da fonte compartilhada). Conta renomeada, removida ou contest apagado ⇒ 401 auth_required na primeira requisição, e o cliente cai no login. Sessão do MOJ não expira por tempo: sem essa checagem uma sessão aberta antes de uma troca de handle seguia autenticada com o login VELHO e o /submit (que faz mkdir -p no dir do usuário) recriava o diretório do nome antigo — resíduo sem account.json que ainda aparecia como "solver" nas estatísticas. Ver lib/auth.sh (_session_account_alive) e server/bin/user-merge.sh (conserto do resíduo).

Index (home)

Rota I/O
/index/news {news:[{id,title,date,summary,url}]}
/index/contests?page=N {open:[…],upcoming:[…],closed:{items:[…],page,per_page,total}} (cada item {id,title,start_time,end_time,problems_count,url,scoreboard_url,report_url?} — virtual_url (/treino/virtual/?c=<id>) nos ENCERRADOS só quando o portão do virtual passa (vr_load menos a prova rodando — 07/10/2026; antes bastava o conf e o card mostrava um botão que dava "indisponível"), num cache por evento (run/index-virtual.tsv, refeito quando um conf muda ou um problema é publicado/despublicado); report_url (/relatorio/<id>/) só quando o admin PUBLICOU o relatório estático (/contest/admin/report-publish) — problems_count é 0 em contest upcoming: contest por vir não revela a quantidade de problemas, mesma regra do placar pré-início + registration:{opens_at,closes_at,late_until,url} quando a INSCRIÇÃO está em vigor (reg_in_force: roster ∧ módulo inscricoes ligado ∧ contas do treino — 07/10/2026; o roster guardado de um contest com o módulo desligado não convida ninguém) — o cartão do front decide "aberta/atrasada/encerrada" pelo relógio do cliente, sem duplicar a regra do lib/registration.sh). Encerrados paginados (20/pág); ?all=1 devolve todos (usado pela página de arquivo /contests/). Contest SUPER SECRETO (conf SECRET=1) não aparece em nenhuma das três listas (nem no /index/status, que também omite o nome na fila por lista).
/index/open_training {top_users:[…],recent_solved:[…],most_solved_week:[…],most_solved_prev_week:[{problem_id,problem_title,solved_count,url}],most_used_editor_prev_week:{top:{editor,count}|null,total,ranking:[{editor,count}]}} (prev_week=resolvedores distintos por problema; editor=mais usado nas aceitas da semana passada, web ou editor declarado; perfil PRIVADO não entra em top_users/recent_solved — o filtro pula p/ o próximo; conta GERIDA de MENOR também não, nas duas). Cache var/open-training.json por EVENTO (.score-dirty OU .treino-list-dirty mais novos = regenera; despublicado some da home; piso 5 min sob rajada). Ninguém espera a regeneração: com cache (mesmo vencido) a resposta é o cache e o refazer vai destacado (setsid, MOJ_OT_REGEN=1, um só por flock -n); só o 1º acesso sem cache nenhum espera

Treino

Rota Auth I/O
/treino/problems — array [{id,title,tags,collections,statement_langs,solved_count,attempted_count,user_rate,difficulty,dirt,public_at?}] (statement_langs = idiomas do enunciado, do sidecar; a lista mostra o selo "EN ES" quando >1) (difficulty = rótulo CANÔNICO veasy|easy|med|hard|new pela taxa POR USUÁRIO user_rate = resolveram ÷ tentaram — faixas .9/.7/.5 em lib/difficulty.sh, fonte única do sistema desde a issue #30; dirt = métrica do resolver ICPC, (submissões de quem resolveu até o 1º AC − ACs) ÷ essas submissões, do tries_to_ac do metrics.json; null no legado json-count) (collections = .moj-meta.json do pacote, um problema pode estar em várias; public_at = epoch da 1ª publicação, vindo do índice de donos — AUSENTE quando desconhecido; alimenta a ordenação "Novidades" do treino). Contagens do STORE NOVO: agregação de users/*/metrics.json (.solved/.attempted, 1 usuário = 1 por problema), sobreposta à base legada var/json-count/ quando existir. Cache var/problems.json invalidado POR EVENTO (gerador server/score/treino-list-gen.sh): composição da lista = stamp var/.treino-list-dirty (foreground sob flock); contagens = var/.score-dirty + piso de 10 min (refresh em BACKGROUND, serve o stale); TTL de 60 min só rede de segurança
/treino/trending — top-10 problemas por submissões (todas) nos últimos 7 dias (janela móvel), p/ o estado inicial do Treino Livre: {success,window_days:7,generated_at,problems:[{id,title,count,url}]} (ordenado por count desc). Anônimo; problema privado NÃO entra (_private: json só em jsons-private/). Cache var/trending.json por EVENTO (.score-dirty/.treino-list-dirty) com piso LONGO de 6h (varre o history de ~927 contas; a janela é semanal) + flock
/treino/problem?id=<id> — {id,title,author,statement_html_b64,time_limits,tags,collections,languages,statement_langs,statements,samples,cpu_needed,same_numa,function_langs} (function_langs = FUNCTION_LANGS do conf do pacote, 2026-09-30: as linguagens de submissão de FUNÇÃO, em que o editor do aluno abre vazio — [] sem a linha; ver PACOTE.md) (cpu_needed/same_numa = CPUNEEDED/SAMENUMA do conf do pacote, 2026-09-24: CPUs por teste de um problema paralelo, 1 e false p/ os comuns — o preflight do contest lê daqui) (samples = [{name,input,output}], os exemplos do enunciado como texto — a MESMA seleção que o HTML mostra, nunca teste oculto; exemplo acima de 4 MB vem como {name,size,too_big:true} sem os bytes; 2026-09-16) (statement_langs = idiomas do enunciado, ["pt"] no mínimo, português primeiro; statements = {"<lang>":{title,html_b64}} das traduções, ausente sem tradução — o PT segue em title/statement_html_b64; a página mostra um chip por idioma; ver PACOTE.md "Idiomas") (author = arquivo author do pacote, verbatim; vários autores juntados por , ; vazio se ausente; collections = coleções do .moj-meta.json; languages = ids de linguagem de submissão permitidos deste problema, [] = todas as PADRÃO — o front filtra o dropdown por essa lista; linguagens EXÓTICAS/opt-in (pddl/grepe/sas/l/lpp/downward) só aparecem quando o problema as DECLARA aqui). Registra problem-view no log de atividade (load_session soft: Bearer presente = login, sem = anon; a rota segue pública)
/treino/admin/activity-log GET .admin feed COMPLETO do treino (6 fontes no instante exato): login (access.log) · submit (history) · verdict (results finalized_at) · read (activity-YYYY-MM.log: problem-view/log-view/source-download) · admin (admin-audit sem ruído de máquina) · calib (tl-report/calib-report, EXCLUÍDO por default — ?kinds=calib inclui). Filtros since/until (epoch), kinds (csv), user, action, limit (≤5000). format=csv = download do range INTEIRO filtrado (Content-Disposition; cabeçalho epoch,datahora,tipo,quem,acao,detalhes,ip) p/ análise externa. Aba 📜 Atividade do admin do treino
/treino/solvetry?user=<u> opc {solved:[ids],attempted:[ids]}
/treino/history?id=<id> Bearer TXT 7 campos tempo:user:probid:lang:verdito:epoch:subid. Veredicto SEMPRE canônico (lib/verdict.sh; pendentes/strings desconhecidas intactos) — o detalhe (testes/pontos/grupos) vem do /submission/summary
/treino/history-full?user=<u> opc TXT 7 campos (todo o histórico). Veredicto canônico (idem acima) — visitante do perfil público vê só o rótulo, sem resumo (summary é só do dono)
/treino/profile Bearer GET: perfil + cota de username + telegram:{linked,username,linked_at} (vínculo do próprio login; sem o telegram_id) · POST {name?,university?}
/treino/profile/password Bearer POST {old_password,new_password}
/treino/profile/username Bearer POST {new_username} → {updated,new_username,username_changes_used,username_changes_remaining,sessions_updated}. máx. 2/ano, cascata nos arquivos de controle incluindo as SESSÕES (rename_contest_sessions: TODAS as sessões daquele login — outra aba, outro dispositivo, token do moj-cli e as de contests que herdam os usuários via USERS_FROM — passam a valer com o nome novo; sessions_updated = quantas; ninguém é deslogado) e as ORGs (orgs_rename_login: o login troca em members/admins de todas; o NOME da org — inclusive a implícita antiga, que vira comum — não muda: é o prefixo dos ids). A POSSE segue o rename (2026-09-18, lib/owner-rename.sh): dono de problema (.moj-meta.json + índice + overlay), de contest (contests/<c>/owner), de coleção e as permissões de criar contest passam ao login novo — o índice/contests/coleções na hora, os metas dos pacotes (1 commit por problema) em background. Antes o owner ficava no login antigo: os problemas sumiam de "Meus" e, como owner também concede acesso, ficava uma posse apontando p/ um login inexistente. O autor dos commits antigos no git não muda. Sufixo de papel é PRESERVADO: sufixo(novo)==sufixo(atual) — .admin troca p/ outro.admin (400 uname_role_suffix se tentar derrubar o sufixo; uname_reserved se usuário comum tentar assumir um)
/treino/profile?user=<u> opc GET visão pública (respeita privacidade): {login,name,university,favorite_editor,has_photo,is_public,created_at} (created_at=epoch de criação da conta, p/ o "membro desde" do perfil; a visão do dono também o traz — que inclui ainda managed:{minor,by,birthdate,note,expires_at}|null p/ conta GERIDA); POST aceita também favorite_editor, profile_public (400 managed_minor se conta gerida de menor tentar tornar público). Conta gerida de MENOR é sempre privada (profile_is_public corta perfil/foto/history/listas da home); link-start do Telegram → 403 managed_minor; login com .managed.expires_at vencido → 403 account_expired
/treino/contest-registration?contest=<c> Bearer INSCRIÇÃO do próprio login num contest que usa as contas do treino (USERS_FROM=treino): individual ou em TIME de até REG_TEAM_MAX (3) contas EXISTENTES. Fica AQUI (e não no contest) porque o token é por ORIGEM — <id>.moj… não enxerga a sessão do treino. GET → {enabled (a inscrição EM VIGOR: roster ∧ módulo inscricoes∧ contas do treino; o POST de quem não está em vigor = 409registration_off), contest, contest_name, start_time, end_time, window:{state:soon|open|late|closed,opens_at,closes_at,late_until,official_start,official_round}, round_kind, gate_active, team_max, teams_allowed, me:{kind:none|individual|team,team?,cohort?,univ?,ai?,flag?}, team:{login,name,captain,members[],invited[]}|null, invites:[{login,name,captain,members}], totals}. POST {contest,action}: register {univ?,ai?,flag?} (a meta pode vir junto; flag inválida = 400 SEM inscrever) · individual-meta {univ?,ai?,flag?} (inscrito INDIVIDUAL declara/edita universidade/IA/bandeira — paridade com o team-meta; vai p/ a entry do roster e materializa no .team do overlay, então placar/🤖/bandeira funcionam igual ao time) · team-create {name,univ?,ai?,flag?} (nome: : vira ∶, até 48 CARACTERES; a escola até 20; o login do time é time-<slug> sem acento — Os Três e Os Tres são o MESMO time, name_taken) · team-invite {login} (o mojinho manda DM ao convidado na hora, com o link /contests/inscricao/?c=<c> de aceitar/recusar — lib/invite-notify.sh; best-effort: sem Telegram vinculado o convite vale igual) · team-accept/team-decline {team} · team-rename {name} · team-meta {univ?,ai?,flag?} (capitão: a universidade vai em .team.univ_short — o renderer do placar exibe "[SIGLA] Nome"; flag = país ISO-2 ou estado br-xx → .team.flag, a bandeira do placar, 400 flag_invalid se não casar; ai = `yes
/treino/profile/photo?user=<u> opc/Bearer GET serve png 100×100 · POST {image_b64} (redimensiona)
/treino/editors — ranking dos editores favoritos declarados {editors:[{editor,count}],total}
/treino/achievements — registro de CONQUISTAS do perfil {custom,version,achievements:[{id,icon,pt,en,es?,kind,params,enabled}]} (es opcional — ausente, a tela cai no en) — serve var/achievements.json (gerido pela aba 🏅 do admin) quando válido, senão o default embarcado (lib/achievements-default.json); avaliação é no CLIENTE. Kinds e formato: PERFIL.md
/treino/problem-stats?id=<p> — estatísticas do problema (métricas, veredictos, por-linguagem c/ solvers distintos, editores, avatares públicos; difficulty/user_rate/dirt canônicos de lib/difficulty.sh — a MESMA conta da lista /treino/problems, issue #30; acceptance_rate continua = taxa POR SUBMISSÃO, só número, nunca rótulo) + séries temporais (fuso America/Sao_Paulo): daily{YYYY-MM-DD:n} (heatmap), monthly[{m,subs,ac}], dow_hour[{dow 0=dom,hour,n}], first_ac_epochs[] (curva de resolvedores), tries[{bucket,n}]+tries_median (subs até o 1º AC), time_to_solve[{bucket,n}]+t2s_median (1ª sub→AC), facts{first_sub_epoch,last_sub_epoch,peak_day,first_solver{epoch,login?,name?}} (login/nome do 1º solver SÓ se perfil público), difficulty_percentile{harder_than_pct,cohort,success_rate} (taxa de sucesso POR USUÁRIO vs. o acervo público do var/problems.json; ranking com suavização de Laplace + midrank — coorte pequena 100% não esmaga a ponta fácil; elegível = ≥5 tentantes, null se coorte <10), runtimes[{lang,t}] (estilo Kattis: t = teste mais LENTO de cada submissão ACEITA, dos results/<subid>.json — só era newmoj) — cache por EVENTO (.score-dirty mais novo = regenera; sem submissão nova vale p/ sempre; piso 2 min sob rajada; flock)

Treino — participação virtual (refazer contest encerrado)

Desenho, regras e blindagem: docs/VIRTUAL.md. Portão único (vr_load, lib/virtual.sh) a cada requisição: módulo virtual ∧ não-secreto ∧ ICPC ∧ encerrado p/ todas as sedes ∧ placar descongelado ∧ todos os problemas públicos no treino. Portão fechado = 404 virtual_unavailable, corpo idêntico ao de contest inexistente. O enunciado NÃO tem rota aqui: vem da pública /treino/problem?id=.

Rota Método Auth I/O
/treino/virtual/info?contest=<c> GET Bearer treino {contest,title,start_time,duration,penalty_minutes,problems_count,rules:{grace_s,max_discards,schedule_max_s},me} — me como em /run; conta de papel recebe me.state:"forbidden"
/treino/virtual/run?contest=<c> GET Bearer treino {contest,duration,penalty_minutes,me:{state:none|scheduled|running|judging|finished|discarded,start,end,final,discards,discards_left,can_discard,official,result,runs:[[seg,pidx,Y|N|X|?,veredicto,subid,lang]],solved,penalty,pending,now}}. Aplica as transições preguiçosas (fim do tempo ⇒ finaliza ou descarta)
/treino/virtual/run POST Bearer treino {contest,action}: start {accept:true,at?} (422 terms_required/virtual_at_invalid; 409 virtual_already = uma vez por conta) · cancel (só agendada; 409 virtual_not_scheduled) · discard (≤ 15 min OU 0 AC, máx. 2; senão 409 virtual_locked) · finish (com 0 AC e não-definitiva vira discard). Conta de papel: 403 role_forbidden. Resposta = a do GET
/treino/virtual/problems?contest=<c> GET Bearer treino {problems:[{letter,id,name,languages,statement_langs}]} — languages é a whitelist do CONTEST. Sem run largada: 403 virtual_not_started
/treino/virtual/friends GET/POST Bearer treino Meus escolhidos: os virtuais que ESTE login quer ver sempre no placar virtual (amigos). UMA lista por conta, p/ todos os contests; só o próprio login lê/escreve (não há parâmetro de usuário; a rota não fala de contest, por isso não passa pelo portão). GET = {logins:[…],max:100}. POST {add?:[…],remove?:[…]} (o 📌 da linha) ou {logins:[…]} (substitui). Tira o próprio login e duplicatas; 422 friends_invalid (login fora de [A-Za-z0-9._@+-]{1,64}) · 422 friends_limit (> 100). Não confere existência da conta
/treino/virtual/feed?contest=<c> GET — {version:2,contest,title,duration,penalty_minutes,problems:[{letter,name}],views:[{id,name,unranked}],teams:[[login,flag,univ_short,nome,univ_full,guest,cohort]],runs:[[seg,tidx,pidx,Y|N|X|?]]} — times do placar público FINAL; views/cohort alimentam o filtro "Placar:" da página e só trazem coortes PÚBLICAS com time no placar público (views vazio com menos de duas); pré-gzipado; 503 virtual_feed_unavailable
/treino/virtual/board?contest=<c> GET — (Bearer marca you) {virtuals:[{login,name,univ,flag,start,used,solved,penalty,official,runs:[[seg,pidx,flag]],you}]} — só participações FINALIZADAS e não removidas

Treino — cadastro & vínculo Telegram (overlay do treino)

Cadastro web-first verificado pelo Telegram (1 Telegram = 1 conta; anti-duplicata). Os endpoints verify/telegram/recover-password são autenticados pelo token do bot (Authorization: Bearer mojb_…, require_bot, segredo em run/secrets/bot.token) — o bot não loga como .admin.

Rota Auth I/O
/treino/signup/start público (POST) {login?,fullname,university?} → {nonce, deep_link, expires_at}. Valida o login (bloqueia sufixo de papel) e cria um nonce (TTL 15 min). Não cria conta.
/treino/signup/status?nonce= público (GET) {status: pending|created|already_linked|linked|expired, login?} — nunca devolve a senha
/treino/signup/verify bot (POST) {nonce,telegram_id,telegram_username?,first_name?,last_name?} → consome o nonce (uso único), anti-duplicata, cria+vincula (created) ou vincula conta logada (linked); devolve {status,login,password?} (senha só p/ DM)
/treino/signup/telegram bot (POST) bot-first (/participar): {telegram_id,…} → cria+vincula ancorado no telegram_id (idempotente) ou already_linked
/treino/recover-password bot (POST) {telegram_id} → resolve o login pelo vínculo, gera nova senha → {status:ok|not_linked,login?,password?}
/treino/telegram/link-start Bearer conta logada gera nonce purpose:link p/ vincular o próprio Telegram (ex.: .admin receber alertas) → {nonce,deep_link,expires_at}. UI: seção 📨 Telegram do perfil
/treino/telegram/unlink Bearer POST {} — desvincula o Telegram do PRÓPRIO login (404 not_linked sem vínculo). Cota anti conta-descartável: usuário comum desvincula no máx TELEGRAM_CHANGE_LIMIT (1)/ano (403 telegram_limit com a data da próxima; histórico em account.json telegram_changes); .admin é livre. Trocar de Telegram exige desvincular ⇒ a cota cobre a troca. A cota sai no GET /treino/profile (telegram.changes_used/limit/remaining/next_available; limit:null = livre)

Treino — painel admin (.admin, Bearer)

Acesso registra IP (REMOTE_ADDR, o da conexão — X-Forwarded-For/X-Real-IP são IGNORADOS: não há proxy na frente do nginx e esses cabeçalhos viriam do próprio cliente; 03/10/2026) e User-Agent na sessão e em var/access.log.

Rota Método Ação
/treino/admin/sessions GET sessões ativas {count,sessions:[{login,name,ip,user_agent,login_at,has_photo}]} (has_photo: a conta tem foto — o painel só pede a foto de quem tem)
/treino/admin/managed-users GET contas GERIDAS (menores, sem Telegram — CONTAS-GERIDAS.md): {users:[{login,fullname,by,note,birthdate,minor,expires_at,disabled,created_at}]}
/treino/admin/managed-create POST cria contas geridas {users:[{fullname,birthdate,login?,note?,expires_at?}]} (1..500; login vazio = slug do nome com dedup; sufixo de papel recusado) → {created:[{login,password,fullname,birthdate}],skipped:[{…,reason}]} — senhas só nesta resposta; audit managed-create
/treino/admin/managed-reset POST {login} (só gerida) → senha nova (user_genpass) devolvida UMA vez + derruba sessões; audit managed-reset
/treino/admin/managed-update POST {login, note?, birthdate?, expires_at?|null, disabled?} — edita .managed; disabled:true = senha-sentinela !…+derruba sessões; disabled:false = reabilita com senha nova devolvida; audit managed-update
/treino/admin/managed-remove POST {login} (só gerida) → mv p/ .removed-users/<login>-<epoch>; audit managed-remove
/treino/admin/achievements POST salva o registro de conquistas do perfil: {achievements:[…]} (valida ids únicos [a-z0-9-], pt/en obrigatórios e es opcional — texto; vazio não é gravado —, kind conhecido, params por kind; grava atômico var/achievements.json; audit achievements-save) ou {restore_default:true} (remove o registro; volta ao default). Erro de validação = 400 achievements_invalid com a mensagem. Aba 🏅 Conquistas do admin do treino; doc: PERFIL.md
/treino/admin/access-log?day=YYYY-MM-DD GET log de acessos (filtra por dia)
/treino/admin/queue GET/POST pendentes por lista + calibração {total_pending,spool_queued,calib_pending,calib_inflight,calib_targeted,lists:[{contest,name,pending}], routing, pending_details} (routing = roteamento do ESCRITOR com shards: {shards,workers:[{shard,alive_age_s,in_submit,in_results,in_other}],orphans,queue_depth,assigned,delivered_5m} — alive_age_s:-1 = worker do shard nunca bateu; orphans = arquivos em s<j> com j>=K, mismatch de JUDGED_SHARDS entre API e daemon) (calib_pending = fila de calibração kind=calibrate, separada de index; calib_targeted = recalibrações direcionadas por host ainda não entregues — uma vez entregue no heartbeat, o comando some do diretório e a calibração passa a contar em calib_inflight, pelo MARCADOR que a entrega deixa em run/updates/inprogress/<host>/cmd-*.json; sem ele o servidor esquecia que o juiz estava calibrando e nenhuma tela mostrava a dirigida). &details=1 → pending_details:[{contest,login,problem,lang,id,since,age_s,state,has_source}] — CADA submissão pendente, com estado no pipeline (`no-spool
/treino/admin/judges GET máquinas de juiz (modelo pull) {online,busy,policy:{parallel,cushion,share_max},machines:[{host,online,busy,status,langs,cage_root,cache,tl,current,current_jobs,queued_calibrate,slots:{free,total,cpus,by_node,smt,max_free_group},hold,partition,topology,config,report}]} — slots.cpus = cpus do MENOR slot (1 em produção; null = agente antigo), by_node = slots por nó NUMA, smt = hyperthreading, max_free_group = maior nº de slots livres num nó; hold = {job,k_slots,numa,since} quando o juiz está SEGURADO p/ um job largo (CPUNEEDED), senão null; current_jobs[] traz slots/test_cpus/par_max/same_numa/cpu_needed do job — a LARGURA concedida: par_max testes ao mesmo tempo × slots/par_max slots por teste (test_cpus CPUs cada) = slots ocupados; cpu_needed = o CPUNEEDED (menor que test_cpus = CPUs a mais pela memória) — o painel Máquinas e o moj judges show escrevem isso por extenso (antes "4 slots, 4 cpu/teste" parecia 4 testes em paralelo; relato de 30/09/2026); policy = a política global de testes em paralelo (chave "*" do judges-config) — current_jobs = TODOS os jobs em execução (multi-slot; UM por slot ocupado, com since = epoch do claim; current = o 1º, compat — a UI da fila itera current_jobs); status = auto-relato do agente novo (ok|draining|disabled, null = agente antigo) e busy-sem-job vira [{kind:"draining"|"disabled"|"unknown_busy"}]; slots:{free,total}; partition = vigente no agente; config = a config DESEJADA (judges-config) ou null; cache = pacotes em disco do juiz (não RAM); report.gpu = GPU de compute comprovada ({vendor:nvidia|amd,names}) ou null
/ops/judge-config GET ?host= / POST {host, partition?:off|numa|cpus:<X>, reserve?, disabled?, parallel_max?} ou {host:"*", parallel?:off|auto, cushion?, share_max?} (admin) config fina POR JUIZ (multi-slot): particionamento da máquina em slots com pinning, cpus reservadas e desabilitar (drena); parallel_max (1..64, default 4) = teto de testes ao mesmo tempo por job neste juiz (só o servidor lê); host:"*" = POLÍTICA GLOBAL de testes em paralelo (parallel off = todo job com par_max 1 — o recomendado em prova; auto = a sobra de slots além do colchão ceil(total×cushion) vira testes em paralelo, até share_max×total slots por job). Vive em contests/treino/var/judges-config.json; o POST funde campos na entrada do host (nunca substitui); o heartbeat entrega ao agente quando muda (cfg_hash = hash SÓ de {partition,reserve,disabled} normalizados — updated_at/by e campos do servidor não drenam o juiz) e o agente aplica após DRENAR os jobs em andamento. CLI: moj judges config
/ops/judge-reset POST {host, action?:kill|restart} (admin) RECUPERAÇÃO sem SSH: kill (default) manda o agente SIGKILL-ar o grupo de processos de cada slot (job inteiro), reportar judge-error/calib-fail (nada espera TTL) e reconciliar a config; restart = kill + o agente se re-executa (register boot:true re-enfileira o que estava atribuído — fila não se perde). Entregue no próximo heartbeat MESMO com o juiz ocupado/desabilitado. CLI: moj judges reset/restart
/ops/calib-cancel POST {id, inprogress?:false} (admin) cancela calibrações do problema na fila: remove pendentes + direcionadas não entregues → {removed_pending,removed_targeted,removed_inprogress,inflight}; as EM EXECUÇÃO só com inprogress:true (senão só contadas em inflight — prefira judge-reset). CLI: moj judges cancel
/ops/judge-results GET ?host=&limit= (admin) relatório de correções por juiz: últimas N correções (run/results/, com host/verdict/duração) + agregado by_host:{total,accepted,judge_errors,avg_duration,last_at}. CLI: moj judges results
/ops/judge-cache POST {host, action?:clearcache} (admin) limpa o cache local de pacotes de um juiz: 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 — devolve {action,host,cmdid,status:"queued"} e o efeito aparece no /judge/list. Use quando um juiz ficou com pacote velho/corrompido em cache
/treino/admin/stats GET {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); problems_public_by_day alimenta o mapa de calor de entrada de públicos (data aproximada; ver public_at)
/treino/admin/response-stats GET tempo de resposta + volume (cacheado): {coverage, overall, per_day, by_dow_hour, subs_per_day:[{day,count}], subs_by_dow_hour:[{dow,hour,n}]}. Tempo só de submissões com finalized_at; volume conta TODAS as linhas do history. EPOCH/UTC
/treino/admin/calib-activity GET volume de calibrações no tempo (cacheado; do log run/updates/log): {calib_per_day:[{day,count}],calib_by_dow_hour:[{dow,hour,n}],total}. run/ pode rotacionar → histórico parcial
/treino/admin/logout-user POST {login} ou {logins:[…]} → remove as sessões (um ou vários)
/treino/admin/lock-user POST {login} ou {logins:[…]} → trava (troca a senha por aleatória) + desloga
/treino/admin/logout-ip POST {ip} → encerra todas as sessões daquele IP (IPv4/IPv6)

Gestão de problemas (Bearer)

O FORMATO do pacote (arquivos, .moj-meta.json, .moj-id), o que são ORGs e COLEÇÕES e o ciclo validar → calibrar → publicar estão em PACOTE.md (fonte única). Aqui ficam só as rotas. Roteiro de montar um pacote: mojtools/README.md.

Backend = repo git LOCAL por problema (MOJ_PROBLEMS_DIR/<org>/<prob>, o servidor commita direto via problem_commit; sem serviço externo), mas o autor só usa o login do MOJ (sem chave/git). Listagens leem o índice de donos contests/treino/var/problem-owners.json (gerado por mojtools/gen-problem-owners.sh; regen em background, TTL PROBLEM_OWNERS_TTL_MIN). O índice é a fonte única: todo problema tem owner (login). Problema sem dono (legado não-migrado) é ignorado no índice; /mine = owner==login (sem casamento difuso). Não há mais "legado".

Controle de acesso — garantido na API, NUNCA só na interface. A fronteira é a ORG: ver o source/pacote/soluções/calibração e editar/operar é p/ MEMBRO da org (require_problem_edit = org_is_member) — sem atalho de .admin. Ver o detalhe/statement (get/validation) é membro da org ou se o problema é público (require_problem_view). Membro da org VÊ TODOS os problemas dela, inclusive privados, em toda listagem/painel (decisão 2026-07-16); problema PRIVADO não é nem LISTADO p/ quem não é membro da org nem colaborador por-problema (as listagens pré-filtram em owners_emit), inclusive p/ .admin — provas em elaboração não podem vazar. Não-autorizado recebe 404 (não revela a existência). Helpers centrais em lib/problems.sh; moj-cli/curl batem na mesma API e não burlam.

Rota Método I/O
/problems/mine GET {problems:[{id,title,author,owner,collections,public,html,claimed}]} — claimed=true se owner==login, senão "provável" (nome casa)
/problems/shared GET problemas compartilhados com o login: tudo que ele pode editar e não é dele — membro da org OU colaborador por-problema (não dono)
/problems/public GET problemas públicos (no treino livre) — visão de gestão (dono/autor)
/problems/collection?name=<c> GET problemas da coleção (curso/diretório, ex.: obi-problems)
/problems/collections GET {collections:[{name,count,public,owner,mine,can_manage}]} — coleções = TAGS curadas (do registro), com contagem visível. Coleção (agrupamento, m:n) ≠ ORG (acesso, 1:1 — ver /orgs/*)
/problems/collection GET ?name problemas de uma coleção (filtra pela tag collections)
/problems/get?id=<id> GET detalhe: índice + validation (relatório do portão) + statement_html_b64/tags + time_limits (EFETIVO) / time_limits_calibrated / tl_override. ⚠ O TL vem do pacote (tl_store_served, override aplicado), não do json servível: o json público só existe depois de publicar — em problema privado (o estado de quem está calibrando) o campo sumia e o editor caía num fallback que mostra o máximo CRU entre juízes — e edit/upload não reindexam, então mesmo público o número podia estar velho. O checksum vem materializado do índice, então não há hash de pacote por request. O índice inclui languages (whitelist de submissão do .moj-meta.json; [] = todas as padrão) — a gestão exibe no detalhe (badges + atalho p/ o widget do editor). rev = revisão do conteúdo do pacote (ver Trava de edição concorrente abaixo) — é o que o moj pull compara antes de baixar o pacote
/problems/validation?id=<id> GET último relatório de validação {checks:[{name,ok,detail}],html_built,render_warnings,ok}
/problems/status[?id=<id>] GET painel dos problemas do login (dono+colaborador+membro da org; privado de org alheia não aparece — owners_visible): {total,counts:{validated,…,needs_recalibration,good_sol_no_tl,public_unvalidated,needs_review,ready,sols_divergent,sols_unchecked,inputs_invalid,issues_open,not_judgeable,errors},calibrating_ids,attention_ids,judge_capacity:{judges,max_mem_mb,slot_mem_mb,max_cpus,max_node_cpus},problems:[{id,title,untitled,owner,author,public,validated,calibrated,sols:{state,bad,note,missing,total,at},inputs:{state,invalid,total},open_issues,judgeable:{ok,code?,need?,max?},ready,pending,being_calibrated,stale,needs_recalibration,good_sol_no_tl,good_sol_missing_langs,public_unvalidated,error,needs_review,review_reasons,time_limits,time_limits_calibrated,tl_override,updated_at}]}. ?id= estreita a resposta a UM problema (é o que a confirmação de publicar usa); id que o login não opera sai vazio, igual a inexistente. judgeable (30/09/2026) = o problema cabe em ALGUM juiz visto nos últimos 7 dias (não desabilitado), pela MESMA regra do escalonador (sched_judgeable/_eff_width: CPUNEEDED, SAMENUMA e MEMLIMITMB como largura), e ao menos uma das languages declaradas roda em algum juiz; não cabe ⇒ {ok:false, code:memory|cpus|numa|langs, need, max}, a pendência not_judgeable:<code>,<need>,<max> (primeira do pending, entra em review_reasons/needs_review) e counts.not_judgeable. Sem juiz conhecido, nada se afirma sobre memória/CPU. judge_capacity = o maior MEMLIMITMB que um juiz aceita (máquina inteira) e o que cabe em UM slot, CPUs da maior máquina e do maior nó — o aviso ao vivo da aba Limites do editor. validated é o estado do PACOTE (conferência estática do validate-problem.sh — não roda solução; a tela diz "Pacote"). sols = as soluções × o que a categoria delas pede, do sumário run/calib-summary.json (gravado pelo /judge/calib-report, regra em lib/calib-expect.sh): state ∈ none (sem resultado) · stale (o pacote mudou em sols/ tests/ scripts/ conf depois da calibração, ou o TL está velho) · bad (≥1 divergente) · partial (solução do pacote sem resultado — calibração rápida só roda as good) · note (tudo conforme, com alguma "por outro motivo") · ok. inputs = o validador de entrada: state ∈ unknown · none (sem validador) · ok · invalid · error. pending = o que falta p/ o problema estar PRONTO (package_failed, package_unchecked, uncalibrated, needs_recalibration, good_no_tl:<langs>, sols_divergent:<n>, sols_unchecked, inputs_invalid:<n>, inputs_error, issues_open:<n>); ready = pending vazio. open_issues = issues abertas (/problems/issues). review_reasons ganha sols_divergent:<n>, inputs_invalid:<n>, inputs_error e issues_open:<n> (entram em needs_review; sols_unchecked não — é pendência, não alarme). untitled = o problema não tem título em lugar nenhum (nem display_title no pacote, nem linha de título no enunciado) e o title devolvido é o identificador — o Painel marca com o selo "sem título" p/ o dono nomear; não é erro. time_limits é o EFETIVO (com o TLOVERRIDE do conf aplicado — o override vem carimbado no índice de donos por gen-problem-owners.sh, então o Painel não abre pacote nenhum); time_limits_calibrated é o cru dos juízes e tl_override é o declarado ({} sem override). good_sol_no_tl = tem solução good sem TL (linguagem suportada que falhou em TODOS os juízes); needs_review = precisa revisão (erro / good sem TL / público não validado ou não calibrado). stale/needs_recalibration do checksum do índice (≤30 min); sem hash de pacote por request; TL/validação vêm dos sumários por-evento run/{tl,validation}-summary.json (upsert pelos escritores; sem varrer run/tl por request)
/problems/tl?id=<id> GET time limits ao vivo (recomputa o checksum agora) + stale/needs_recalibration exatos: {problem,checksum,time_limits,time_limits_calibrated,tl_override,calibrated_checksum,hosts,updated_at,calibrated_at,calibrated,being_calibrated,stale,needs_recalibration}. time_limits = o EFETIVO (o que o aluno vê e o juiz honra): 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). being_calibrated = há calibração pendente/em execução p/ este problema AGORA (mesma varredura do painel) — distingue "TL vazio porque acabou de enfileirar (validate/calibrate)" de "calibrou e não obteve TL". Quando needs_recalibration, explica o PORQUÊ: reason (checksum velho→novo), changes = commits desde a calibração que tocaram os caminhos que afetam o TL (conf/tests-input/sols-good/scripts — o que o tl-checksum cobre; [{sha,at,author,subject}], ≤20) e changed_files (≤30). Acesso: membro da org ou público (require_problem_view; 404 senão). Versão não-admin do /ops/problemtl. Python é UMA linguagem: py (pypy3) — chaves py3/py2 legadas são fundidas em py nos time_limits servidos (o cru de hosts pode ainda trazê-las até recalibrar)
/problems/recalibrate-stale POST {} | {ids:[...]} recalibra em LOTE tudo que "precisa recalibrar" no painel do login (calibrado + checksum divergente — mesma conta do /problems/status); ids restringe (intersectado com o conjunto AUTORIZADO — a fronteira é owners_visible, nunca o input). Cada item via cal_request (idempotente + serializado por-problema no claim — lote é seguro). Resposta {count, queued:[{id,reqid}]}. Web: botão "⚙ Recalibrar todos (N)" no Painel; CLI: moj calibrate --all-stale
/problems/calib?id=<id> GET calibração por juiz (membro da org): {id,checksum,version,being_calibrated,calibrating:[{host,since,state}],good_langs,missing_langs,tl_override,time_limits,time_limits_calibrated,hosts:[{host,tl,missing,at,version,stale,log,reports,sols}]}. being_calibrated/calibrating = o que está EM VOO p/ este problema AGORA (fila + em execução, inclusive a dirigida, pelo marcador de run/updates/inprogress/<host>/cmd-*.json); state ∈ `queued
/problems/issues?id=<id> GET issues do problema (a revisão interna da banca; lib/problem-issues.sh): {id, open, issues:[{n,title,body,state:open|closed,by,at,updated_at,closed_by,closed_at,comments:[{by,at,body}]}]} — abertas primeiro, depois as mais recentes. Acesso: require_problem_edit (quem não edita recebe o MESMO 404 de problema inexistente). Loja: contests/treino/var/problem-issues/<org>/<prob>.json (FORA do pacote: não muda o rev, não some no upload, não vai ao juiz); sumário problem-issues-summary.json {id: abertas} p/ o Painel. by é texto histórico (não entra na cascata de rename). Move leva as issues; delete as apaga. Web: aba 🐞 Issues do editor; CLI: moj issues
/problems/issues POST {id, action, n?, title?, body?} action ∈ open (title 1..200, body opcional) · comment (n, body obrigatório) · close / reopen (n; body, se vier, entra como comentário). Corpo ≤ 20.000 caracteres, ≤ 200 comentários por issue, ≤ 500 issues por problema. Resposta {id, open, issue}. Erros: 400 issue_invalid (a mensagem diz o quê), 404 issue_notfound, 409 issue_state (fechar fechada / reabrir aberta), 422 issue_limit. Issue aberta = pendência issues_open:<n> no /problems/status (o problema não está pronto)
/problems/calib-report?id=<id>&host=<host>&name=<name> GET o report.html rico (o do build-and-test) de UMA solução, como saiu da calibração NAQUELE juiz (run/calib/<id>/r/<host>/<name>.html). Os nomes válidos vêm de hosts[].reports do /problems/calib. Devolve HTML, não JSON. Acesso: require_problem_edit — dono/colaborador, sem atalho de .admin (é código de solução). CLI: moj calib-report
/problems/my-stats GET análise dos problemas do login (dono+colaborador) agregada em TODA a plataforma (treino + turmas; cache precomputado). {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}]}. Só os problemas do login; sem logins, sem nomes de contests (só contests_count) — não vaza prova privada
/problems/judges GET o parque de juízes para a calibração DIRECIONADA do editor: {judges:[{host,cpu,arch,langs,cage_root,last_seen,online}]}, ordenado por online › cpu › host (online = heartbeat nos últimos 30 s). O editor agrupa por cpu para oferecer "1 por processador". Só exige login (é inventário de máquina, não conteúdo de problema). CLI: moj calibrate --judges
/problems/validate POST {id} portão de qualidade, NÃO publicação: valida (portão estático: HTML compila + seções ## Entrada/## Saída + exemplos pareados) + gera o índice + pede calibração a um juiz (que roda as good e reporta o TL). NÃO mexe no public — problema privado continua privado (publicar é /problems/set-public, que checa a trava da ORG). Relatório: /problems/validation. Só membro da org.
/problems/publish POST {id} DEPRECADO — alias de /problems/validate (o nome fazia parecer que validar publicava)
/problems/request-calibration POST {id, hosts?:[...]} enfileira calibração (juiz roda calibreitor.sh, gera tl.<host>). IDEMPOTENTE: se já existe calibração pendente/em execução p/ o id, devolve o reqid existente com status:"already_queued" (nunca duplica job — lição do incidente 2026-07-15); direcionada (hosts) dedupa por host os comandos ainda não entregues (hosts[].status)

Autoria (escrita keyless — git escondido, commit autorado pelo login via problem_commit)

Rota Método I/O
/problems/repos GET diretórios/orgs de que o login é membro {repos:[{repo,owner,collaborators,collections,mine}]}
/problems/repo-create POST {repo, collections?} cria o diretório (org no namespace do login; provisiona a org implícita lazy)
/problems/source?id=<id>[&tests=meta|full] GET source editável {editable,rev,rev_by,rev_at,title,titles,statement_langs,translations,enunciado_md,enunciado_format,author,tags,conf_text,public,collections,languages,examples,tests,sols{good,slow,wrong,pass,upcoming},score,score_text,editorial_md,scripts,scripts_files,docs_files} (translations = {"<lang>":{title,enunciado_md,editorial_md?,notes?:{"<sample>":md}}} das traduções docs/enunciado.<lang>.md/solucao.<lang>.md/notes/<sample>.<lang>.md; titles = {"<lang>":título} do meta; statement_langs = ["pt",…]; PT segue nos campos de sempre — 2026-09-15) SÓ MEMBRO da org (require_problem_edit); não-autorizado recebe 404 (sem read-only, sem atalho de .admin). rev/rev_by/rev_at = revisão do conteúdo + autor e EPOCH do último commit de conteúdo (a base da trava: o editor web e o moj clone/pull guardam o rev e o devolvem como base_rev). Cada examples[i] traz explanation (opcional); editorial_md = resolução só p/ setter; scripts = caminhos relativos de scripts/ (árvore do editor web); scripts_files = ROUND-TRIP da correção especial — [{path,content_b64,exec} | {path,symlink}] (base64 suporta binário; symlink cobre os drivers interativos scripts/<lang> -> c); score_text = tests/score cru (round-trip byte-fiel do moj push/clone); languages = ids de linguagem de submissão permitidos (.moj-meta.json, [] = todas); docs_files = ROUND-TRIP das IMAGENS de docs/ — [{name,content_b64}] (figuras do enunciado/notas; nomes simples com extensão de imagem). examples[i].explanation vem de docs/notes/<sample>.md (formato de autoria) ou do legado sample-notes.json. tests=meta (DEFAULT): os testes OCULTOS saem sem conteúdo — {name,size_in,size_out,omitted:true} — e a resposta traz tests_omitted:true; o conteúdo de um teste vem por /problems/test. Motivo: problema com testes grandes (OBI: inputs de 12 MB) gerava corpo de centenas de MB (52 s medidos) e o editor web ficava todo esse tempo com o formulário VAZIO, idêntico ao de "problema novo". tests=full devolve o conteúdo (é o que o moj clone usa — round-trip). Ao salvar, teste com {name, keep:true} preserva o conteúdo que está no servidor (o editor manda isso para os testes que não baixou)
/problems/preview POST {enunciado_md, enunciado_format?, examples?, title?, id?, images?, lang?} ou {kind:"editorial", markdown, id?, images?, lang?} pré-visualização HTML (= o renderizador único render-statement.sh, idêntico ao servido) — injeta o título (h1) e os exemplos (cada explanation renderizada em markdown com embed). lang (pt|en|es, default pt; 400 lang_invalid) = rótulos dos exemplos (Exemplos/Examples/Ejemplos…) e <html lang>; o cliente manda texto e explicações JÁ no idioma (o HTML dos exemplos sai do stmt_samples_html do mojtools, o MESMO do índice). kind:"editorial" renderiza só o markdown, sem exemplos e sem h1 — o botão Pré-visualizar da aba Resolução. Resposta {html_b64, lang, kind}. Imagens-arquivo aparecem: com id (exige require_problem_edit) as imagens de docs/ do pacote são semeadas no render; images:[{name,content_b64}] (≤16, nomes de imagem saneados) cobre figura ainda não enviada → {html_b64}
/problems/download?id=<id>[&sha=<sha>] GET baixa o pacote .tar.gz (inclui soluções → membro da org); com sha, a versão daquele commit (git archive, worktree intocado); stream binário
/problems/test?id=<id>&name=<teste> GET conteúdo de UM teste {name,input,output} — o par do tests=meta; mesmo gate do source (só membro da org; 404 p/ os demais)
/problems/test-run POST {id, filename, code_b64} roda UMA solução avulsa NO JUIZ (autoria): 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 → {run:<32hex>, status:"queued"}. Gate: membro da org (require_problem_edit, 404 — rodar contra os testes ocultos revela o problema). Teto SUBMIT_MAX_KB (413); linguagens aceitas = PLATAFORMA ∪ languages do pacote (a whitelist de SUBMISSÃO do problema não vale aqui — autor testa o que quiser que rode); rate: máx 3 runs queued por login (429 testrun_busy); auditado (test-run). Registro em run/testrun/ com TTL de 7 dias (GC preguiçoso)
/problems/test-run?run=<32hex> GET polling do test-run: {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)
/problems/test-run-report?run=<32hex> GET o report.html do test-run (HTML; 404 enquanto julga/expirado). Mesmo gate do registro
/problems/history?id=<id>[&limit=N][&sha=<sha>] GET histórico git do problema (membro da org — expõe soluções/testes). Sem sha: {id,commits:[{sha,at,author,subject,files,insertions,deletions}]} (limit≤200, default 50). Com sha: o git show -p → {sha,at,author,subject,truncated,diff_b64} (diff limitado a 400 KB)
/problems/restore POST {id, sha, confirm} restaura o problema ao estado do commit sha como um COMMIT NOVO (história nunca é reescrita; confirm repete o sha). O .moj-meta.json (público/coleções/owner) é PRESERVADO — meta antigo não republicaria prova privada. Sem revalidação/recalibração automática (igual ao edit). Membro da org
/problems/upload POST {id|repo,prob, tar_b64, base_rev?, force?} sobe um pacote (.tar/.tar.gz/.tar.bz2/.tar.zst/.zip) e substitui o conteúdo (commit). Do .moj-meta.json do tar lê os campos de CONTEÚDO — display_title, collections, languages (ausente/[] ⇒ preserva); os de ACESSO (public/public_at/owner) nunca vêm do tar. Tar sem o arquivo tags ⇒ preserva as do servidor (curadoria); com (mesmo vazio) ⇒ substitui. base_rev/force = a MESMA trava do edit (409 stale_rev); resposta traz o rev novo
/problems/export?id=<id> GET baixa o problema como pacote ICPC/Kattis (2025-09) .tar.gz (problem.yaml+statement+data+submissions); inclui soluções → exige escrita/admin (mojtools/kattis/export.sh)
/problems/import POST {repo, prob?, tar_b64} importa um pacote ICPC/Kattis (mojtools/kattis/import.sh) → cria um problema MOJ julgável (checker custom via bridge); exige permissão de criação. Round-trip sem perda via .kattis.json
/problems/create POST {repo,prob,enunciado_md?,author?,tags?,examples?,good_sol?,title?,collections?,languages?,...} cria problema novo; commit+push; {id,sha,rev}. prob = slug minúsculo ^[a-z0-9][a-z0-9._-]{1,80}$ (400 prob_invalid); collections tem de EXISTIR no registro curado (400 coll_unknown — MESMA trava do edit/set-collections; a homônima da org é isenta). collections ausente OU vazio ([] — o editor com o campo em branco e o moj new mandam assim) = a coleção homônima da org, gravada explícita no meta (até 25/09/2026 só o ausente; o [] ia p/ o meta e o treino não listava a coleção). languages = ids permitidos de submissão ([]/ausente = todas)
/problems/edit POST {id, base_rev?, force?, ...campos} edita (só campos presentes); commit+push autorado; resposta {action,id,sha,rev}. base_rev ≠ rev atual e sem force:true ⇒ 409 stale_rev e nada é gravado (ver Trava de edição concorrente). Aceita translations ({"<lang>": {title?, enunciado_md?, editorial_md?, notes?:{"<sample>":md}} | null} — idioma ausente = intocado; null = apaga enunciado/editorial/notas/título do idioma; dentro do idioma, campo ausente = intocado e "" = apaga; notes presente SUBSTITUI as notas daquele idioma; só en/es) e titles ({"<lang>":título}, mesclado no meta; o servidor só guarda idioma com arquivo). Salvar as examples[].explanation PT nunca apaga nota traduzida. Aceita languages (ids de submissão permitidos no .moj-meta.json; ausente = não toca, [] = limpa/todas). Aceita também scripts_files (SUBSTITUI scripts/ inteiro quando presente — paths validados, sem .., confinado a scripts/, exec vira +x, symlink recriado se o alvo resolvido fica dentro de scripts/; campo ausente = não toca) e score_text (grava tests/score verbatim; "" remove). Aceita docs_files (SUBSTITUI as imagens de docs/ quando presente — nomes saneados, só extensão de imagem, cap ~3MB; ausente = não toca). examples[].explanation grava docs/notes/<sampleN>.md (1 markdown por exemplo — e REMOVE o legado sample-notes.json). Mexer em scripts/ muda o tl-checksum ⇒ recalibração. O editor web gere a correção especial na sub-aba "⚙ correção" (Soluções & Correção — lista + templates) e envia scripts_files no save
/problems/script-templates GET templates de corretor especial (lidos de mojtools/script-templates/<key>/ — criar template = criar uma pasta lá): {templates:[{key,name,description,conf_hints,slots,function,files:[{path,content_b64,exec} | {path,symlink}]}]} (function:true = template de submissão de função: aplicá-lo marca as linguagens dos <lang>/compile.sh no FUNCTION_LANGS da aba Limites) — files no MESMO shape do scripts_files (aplicar = preencher a seção da UI e salvar). Symlink externo do template (drivers canônicos do mojtools) vem RESOLVIDO como conteúdo; symlink interno (cpp -> c) vem como symlink. Iniciais: checker-testlib, interativo, interativo-rank, compare-float, ban-funcoes-c
/problems/delete POST {id, confirm} REMOVE o problema (git rm da subpasta + push) e do treino. Destrutivo: confirm tem de repetir EXATAMENTE o id. Dono/colaborador ou admin
/problems/set-public POST {id, public:bool} público on => valida + calibra (index_problem_bg no servidor; só entra no treino se o portão passar) e grava public no .moj-meta.json; off => sai do treino na hora. A calibração só entra na fila se o pacote MUDOU desde a última calibrada (tl-checksum atual ≠ checksum do store servido) — resposta traz calibration:"queued"|"up_to_date"; publicar em massa sem mudança não enfileira recalibração redundante
/problems/set-collections POST {id, collections:[...]} define as coleções (tags) do problema no .moj-meta.json; valida contra o registro (curada: a coleção tem de existir)
/problems/move POST {id, to_org} move um problema de rascunho p/ outra org (muda o id <org>#<prob>); bloqueia se público/em uso (senão órfãoria o histórico); exige ser membro das DUAS orgs
/problems/repo-collaborators GET ?repo / POST {repo,add?,remove?} compartilha o diretório (membro da org; só o dono gerencia). Cada login em add precisa existir no treino e poder criar problemas (cc_can_create) — senão 422 login_invalid/404 user_notfound/403 cannot_create, recusa ATÔMICA; remove não valida
/problems/collection-create POST {name} cria uma coleção (TAG) no registro curado. Nome é TEXTO LIVRE (pode ter espaços/acentos — é só rótulo). Exige permissão de criação; criador = dono. (NÃO é org: acesso é por org)
/problems/collection-rename POST {name, to} renomeia a coleção: registro NA HORA + re-tag dos N problemas em BACKGROUND (retag:"background", devolve retag_job p/ acompanhar; síncrono estourava o timeout do nginx). RETOMADA: name inexistente + to existente = bulk anterior morreu ⇒ repete só o retag (resumed:true). Só dono ou .admin
/problems/collection-delete POST {name} exclui a coleção: untag dos N problemas em BACKGROUND (devolve retag_job) e o registro só sai NO FIM (untag:"background"; morreu no meio ⇒ a coleção ainda existe, repetir o delete RETOMA). Só dono ou .admin
/problems/collection-retag-status?[job=<id>][&name=<coleção>] GET situação dos jobs de retag (rename/delete): {jobs:[{id,from,to,by,started_at,total?,done,failed,finished_at?}]} mais novos primeiro (últimos ~50); sem finished_at = rodando (done/total = progresso, total é estimativa). job= filtra pelo id devolvido em retag_job; name= por from/to

source/create/edit cobrem o pacote inteiro: title (vem do campo, não de % Título no texto — o render injeta o h1), enunciado_md, conf_text (TL/ulimits/STOPWHEN/…, ver saad-problems/README.org), examples (sample; cada um aceita explanation opcional → docs/sample-notes.json, mostrada após o exemplo), tests (ocultos), sols por categoria {good,wrong,slow,pass,upcoming} (cada [{filename,code}]), score (grupos de pontuação; cada grupo tem {name,weight,glob} e o glob pode ser uma lista ", "-separada de padrões, ex.: g2_*, g3_*) e editorial_md (resolução em markdown → docs/solucao.md, só p/ setter, não vai ao aluno).

Trava de edição concorrente (rev, 2026-09-22). rev = revisão do CONTEÚDO do pacote (pkg_rev em lib/problems.sh: hash de git ls-tree HEAD sem a linha do .moj-meta.json + os campos de autoria do meta — display_title, titles, languages, collections). Não é o sha do HEAD de propósito: set-public, move e a troca de username (owner-rename) também fazem commit e não podem travar o autor à toa; coleção ENTRA porque o push manda a lista inteira. Quem lê (source/get/create) recebe o rev; quem grava (edit/upload) manda base_rev = o que tinha. Diferente ⇒ 409 {error:{code:"stale_rev", message, current_rev, changed_by, changed_at}} e nada é gravado; force:true grava por cima; sem base_rev = o comportamento de sempre (CLI antiga, scripts). A conferência, a escrita e o commit acontecem sob o MESMO lock por problema (problem_lockfile; o problem_commit não trava de novo com _PC_LOCK_HELD=1) — dois saves com o mesmo base_rev ao mesmo tempo: um vence, o outro leva 409. Clientes: o editor web (caixa "alterado por X" com Recarregar / Salvar por cima) e a CLI (moj push/upload recusam; --overwrite = force; moj pull traz). Teste: server/test/smoke-problem-rev.sh.

Quem pode criar (problemas/pastas/coleções) = mesma regra de criar contest (cc_can_create: .admin ou allowlist ou ≥ N resolvidos, menos a denylist) — gerida em /treino/admin/contest-perms. create/repo-create/collection-create/upload-novo exigem isso; editar/compartilhar problema existente continua por colaborador (org_is_member).

Orgs (modelo MOJ-nativo)

Conceito completo (ORG = acesso, COLEÇÃO = agrupamento, e por que são ortogonais): PACOTE.md.

Storage = repo git local por problema (MOJ_PROBLEMS_DIR/<org>/<prob>), e o acesso é por ORG (o <org> do id <org>#<prob>): quem é membro escreve em qualquer problema da org; a org tem uma trava de público (public_allowed, privada por PADRÃO → problemas nunca ficam públicos: anti-vazamento de prova), e só admin da org a muda. Cada usuário tem uma org implícita <login> (sempre privada). Registro: contests/treino/var/orgs.json (lib/orgs.sh).

Rota Método Descrição
/orgs/list GET orgs de que o login é membro (inclui a implícita, criada aqui): {orgs:[{name,title,members,admins,public_allowed,implicit,count,public,mine,can_manage}]}. Não lista org alheia
/orgs/get GET ?name detalhe de 1 org; só membro/admin ou .admin global, senão 404 (não vaza existência)
/orgs/create POST {name,members?,admins?,title?,public_allowed?} cria org; o criador vira membro+admin (exige cc_can_create, a regra de criar contest). Cada login de members/admins precisa existir no treino e poder criar problemas — 422/404/403 senão (recusa ATÔMICA: a org nem nasce)
/orgs/members GET ?name / POST {name,add?,remove?,admins_add?,admins_remove?} só admin da org (ou .admin) gerencia; criador blindado; org implícita não tem gestão. add/admins_add validam cada login (existe no treino + cc_can_create; 422 login_invalid/404 user_notfound/403 cannot_create, atômico); remove/admins_remove não validam (lixo já gravado precisa poder sair)
/orgs/set-public-allowed POST {name,public_allowed:bool} liga/desliga a trava (só admin da org; implícita ⇒ 409). Desligar DESPUBLICA em cascata os problemas públicos da org (tira do treino) — resposta traz unpublished
/orgs/delete POST {name} remove uma org VAZIA (sem problemas — conferido em disco); só admin da org (ou .admin); org implícita ⇒ 409 implicit_org; org com problema ⇒ 409 org_not_empty

O CLI moj (web/moj, servido em GET /moj; fonte em moj-cli/) usa essas rotas para autoria sem git/sem chave: moj new/clone/push/publish/share/org/mv. Storage MOJ-nativo: o servidor commita no repo git LOCAL de cada problema (MOJ_PROBLEMS_DIR/<org>/<prob>).

Permissão de escrita = membro da ORG do problema (org_is_member; sem atalho de .admin). Visibilidade imediata via overlay contests/treino/var/authored.json (mesclado ao índice). Público só se a org permitir (public_allowed) — camada anti-vazamento de prova.

Submissão (assíncrona)

Rota Método Auth I/O
/contest/beacon?contest=<c> GET Bearer → {beacon,server_utc}. Beacon de tempo p/ a submissão offline (moj-comp): payload_b64.sig_b64, payload {v,c,l,t,n} assinado RSA-PSS com a chave do contest. A CLI re-ancora a cada comando com rede; o beacon embutido no pacote offline prova que ele nasceu depois de .t (piso do carimbo). Ver lib/contest-offline.sh e FLOW.md §offline.
/contest/offline-submit?contest=<c> POST Bearer body {packets:["<pkt-json>",…]} (máx 50). Rota emergencial do moj-comp: pacotes cifrados (RSA-OAEP+AES-256-CBC com sha do conteúdo no envelope) criados SEM rede. Valida por pacote: decripta; v/login/contest conferem; beacon assinado do mesmo login/contest; beacon.t ≤ claimed_utc ≤ now+30s; claimed na janela DO aluno (start…fim efetivo, extend conta); claimed monotônico vs último aceito; dedup por sha256; extensão na whitelist de linguagens do problema (mesma regra do /submit; fora dela = pacote rejected na chegada); problema DA PROVA (contest_problem_ok, como o /submit; fora = rejected "problema não pertence a este contest"). Aceito ⇒ spool com time=claimed (contabiliza no horário reivindicado — placar/penalidade usam sub_epoch) + var/offline-log + audit (offline-submit, com gaps beacon→claimed→chegada p/ o organizador adjudicar). → {results:[{sha,status:accepted|rejected|duplicate,…}],accepted,rejected}
/submit?contest=<c> POST Bearer body {problem_id,filename,code_b64,source?} (source=web|file) → {submission_id,status:"queued",epoch,problem_id,lang} (não bloqueia; epoch/lang = o que foi gravado na linha pendente do history — a web a põe na tabela na hora, shared/submit-ux.js). Teto de envios NA FILA (01/10/2026): no treino (inclusive a participação virtual) e em todo contest de LISTA (CONTEST_PRIORITY lista-publica/lista-privada; ausente = lista-publica), a conta tem no máximo SUBMIT_MAX_INFLIGHT (padrão 3; conf do contest ou env da API; 0 desliga) envios esperando o juiz — o próximo leva 429 submit_busy com error.inflight/error.max, e nada vai ao spool nem ao history. O servidor NÃO olha conteúdo (reenviar o mesmo código é permitido); envio segurado na revisão manual (review/<id>.json) não conta; papel (.admin etc.) é isento; conferência e gravação sob flock por login (POSTs paralelos não passam juntos do teto; 409 busy se o lock não vem em 10 s). Prova (prova/super) não tem teto: lá o time com mais de SUBMIT_DEPRIO_PENDING (5) envios esperando perde prioridade no escalonador (judged.sh deprio_delay, ver judge-gw/PULL.md). A linguagem é a extensão, CANONICALIZADA na porta (lang_canon_ext, lib/langs.sh): C++ = .cpp, .cc, .cxx, .c++ (e .hpp) → CPP; .h → C; .py2/.py3 → PY. É o canônico que vai ao spool (lang), ao history, ao archive (submissions/<id>.cpp) e ao juiz — antes ia a extensão crua (CC) e o julgador morria em "Language 'cc' not availale" (2026-09-14). O filename mantém a extensão original. O filename é NORMALIZADO pelo servidor (safe_src_filename): o cliente manda o que quiser, o juiz recebe um nome sadio — 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 (em Java o arquivo tem de casar a classe pública). Sem isso o mesmo código dava AC como l.cpp e Compilation Error como l(1).cpp — o nome chega cru ao recipe do make, que o entrega ao /bin/sh (relato de time, 2026-08-24). Vale igual no /contest/offline-submit e no /problems/test-run. Teto de fonte SUBMIT_MAX_KB (1024) → 413 source_too_large; whitelist com CHÃO: lista de linguagens vazia = as da PLATAFORMA (PLATFORM_LANGS, as 17 de mojtools/lang/), nunca "qualquer extensão" (.exe → 400 lang_not_allowed); e o submit é fail-closed: o spool é validado ANTES do OK (falha → 500 spool_write_failed, sem linha pendente no history) — as três correções do incidente 2026-08-19. Registra o editor em var/editor-log p/ o card "editor da semana". Gate por fase+papel (forçado pela API): .admin/.judge submetem sempre; .staff nunca (403 submit_forbidden); usuário normal e .mon só durante a janela (403 contest_not_started antes do início, 403 contest_ended após o fim) — o .mon submete mas fica fora do placar. Whitelist de linguagens FORÇADA (400 lang_not_allowed): a extensão do filename (canonicalizada: py3→py, cc/cxx→cpp…) tem de estar na lista efetiva do problema — override do contest (problem-langs.json) → LANGUAGES do conf → languages do pacote → todas (fonte única lib/langs.sh, a MESMA da listagem /contest/problems). Campo opcional virtual:"<cid>" (só com contest=treino; docs/VIRTUAL.md): etiqueta a submissão como parte da participação virtual do login — mesmo portão das rotas do virtual (404 virtual_unavailable), problema tem de ser da prova (400 virtual_problem), run tem de estar rodando (403 virtual_not_running) e a whitelist de linguagem passa a ser a do CONTEST. No TREINO o problema tem de ser VISÍVEL ao login (404 problem_notfound): público no índice (var/jsons/), ou privado de que ele é dono/colaborador/membro da org; privado alheio e id inexistente respondem IGUAL (a resposta não confirma existência) e nada entra na fila; índice de donos indisponível = recusa (fail-closed). Antes, um id privado conhecido era julgado p/ qualquer conta (2026-09-18). Em CONTEST o problema tem de ser DA PROVA (contest_problem_ok, lib/langs.sh: o id canônico org#prob — o que o /contest/problems devolve — ou as formas cruas do PROBS): fora dele = o MESMO 404 problem_notfound, nada vai à fila (até 03/10/2026 qualquer id do banco era julgado, inclusive privado alheio e problema removido).
/submission/source?contest=<c>&id=<subid> GET Bearer código-fonte (texto). Só o DONO da submissão e juiz/admin (403 source_forbidden). O mesmo corte vale p/ /submission/log (403 log_forbidden) e /submission/summary (id alheio é omitido). A opção SHOWCODE/show_code, que abria fonte, report e resumo de TODOS a qualquer login do contest, foi removida em 2026-09-18: linha SHOWCODE em conf antigo é morta
/submission/log?contest=<c>&id=<subid> GET Bearer log do julgamento (report.html; expõe input+diff de TODOS os testes). Em repouso é mojlog/<id>.html.gz (2026-09-16): com Accept-Encoding: gzip sai com Content-Encoding: gzip, senão descomprimido; report apagado pela retenção com results/<id>.json presente = nota bilíngue "removido pela política de retenção". Juiz/admin sempre; dono conforme o SHOWLOG efetivo (showlog_effective em lib/verdict.sh): SHOWLOG explícito no conf manda; ausente = OCULTO em modo icpc (anti-vazamento de prova) e visível nos demais modos
/submission/summary?contest=<c>&ids=<csv> GET Bearer resumo ESTRUTURADO em lote (p/ a linha de detalhe sob o veredicto canônico), de results/<id>.json: { "<id>":{verdict,verdict_canon,score,score_max,score_kind,correct,total,groups,heur_score?,heur_adjusted?} } (veredicto manual com texto p/ o time: nos níveis score/none verdict = o TEXTO do time e verdict_canon = a classe; o daemon grava verdict_team no results). REDIGIDO pelo modo do contest (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 (anti-leak: nem o dono recebe score) — nos níveis redigidos verdict = canônico. Juiz/admin: sempre full com verdict cru. Mesmo gate do log (dono/admin/juiz; respeita o SHOWLOG efetivo — explícito manda, ausente = oculto em modo icpc); ids de terceiros são omitidos (não 403). score_kind ∈ tests|points; groups = [{earned,max},…] na ordem do tests/score (earned null = grupo não executado). Até 1000 ids; results antigos: verdict_canon derivado da string e groups da cauda legada Pontos | … | (com max:null)

Contest

Rota Auth I/O
/contest/basic?contest=<c> — {contest_id,contest_name,start_time,end_time,login_start_time,locale,login_enabled,freeze_time,score_anon,languages[],secret,balloons_during_freeze,modules[]} (balloons_during_freeze, 03/10/2026: a POLÍTICA de balão no freeze — a página do staff avisa a hora do congelamento; a contagem de retidos fica só no staff/queue do admin) (modules = ids dos MÓDULOS ligados do contest — CONTEST_MODULES do conf, catálogo em lib/modules.sh; decide quais grupos/painéis o admin e o chefe mostram e, desde 03/10/2026, se a regra do módulo vale (desligado = gate/trava/prorrogação/roster/coortes/balões não valem); o acesso continua cortado em cada rota; languages = whitelist do conf LANGUAGES=; [] = todas; locale = pt/en/es explícito impõe o idioma da interface do contest (e o do papel impresso — folha de rosto e de balão —, do relatório offline e das DMs de convite), "" = não setado ⇒ o front cai no seletor/idioma do browser; round = {slug,name,kind,warmup} da rodada ATIVA ou null — é o que faz o front avisar em faixa fixa que aquilo é AQUECIMENTO; cohort = {id,name,unranked,public,view,released,views[]} da coorte do login (só com sessão; null sem coortes) — o front avisa o convidado e mostra o seletor Oficial × Geral; score_views = [{id,name}] das coortes PÚBLICAS com placar próprio (ranking:true — ex.: times × individual), lista pública que vira o seletor do placar). Continua público mesmo em contest secreto — a tela de login/countdown precisa do nome p/ quem tem o link. Com Bearer de sessão deste contest (opcional), end_time é o fim EFETIVO do login (prorrogação por sede/grupo via time-overrides.json — o countdown mostra o certo) Inclui balloon_style (icon|fill, default icon) = como o placar pinta a célula resolvida — icon: fundo neutro + ponto da cor (a cor deixa de ser o único sinal de "resolvido"; o balão BRANCO da paleta padrão dava 1,00:1 contra o fundo e sumia) · fill: cor do balão no fundo + contorno derivado. Vale p/ placar, cerimônia e relatório; ver SCOREBOARD.md. Inclui penalty_minutes (regra de pontuação, não segredo — a cerimônia de revelação precisa dela p/ recalcular penalidade e ORDEM; antes só existia no /contest/admin/settings, admin-only, e o telão caía no default 20). Resposta em CACHE por VARIANTE (`var/basic-cache.<anon
/contest/userinfo?contest=<c> Bearer {login,name, …team/país/univ/show_log/show_editor opcionais, code_templates} (code_templates (2026-09-30) = o módulo esqueletos vale: ligado E com o editor embutido — esq_effective, lib/esqueletos.sh; o editor busca então /contest/esqueletos)
/contest/esqueletos?contest=<c> Bearer (qualquer conta do contest) esqueletos de código personalizados do contest (módulo esqueletos): {on:true, langs:{<lang>:{mode:"custom",code} | {mode:"off"}}} — linguagem ausente = o PADRÃO, que mora só em web/shared/languages.js. Módulo desligado ou editor embutido desligado = 404 module_off (conferido a cada requisição). Não espera o início da prova: o esqueleto é por linguagem, sem nada do problema
/contest/navbuttons?contest=<c> Bearer botões por papel (SEM emoji desde 2026-09-15, issue #28; o competidor ganha Minhas submissões → /contest/submissions/, issue #26; .admin/.judge/.staff/.cstaff — o .cstaff ganha Etiquetas e, quando o contest terminou p/ todas as sedes (e, com o telão do Animeitor em uso, o .animeitor liberou a revelação — site_reveal_open), 🏆 Revelação; o .staff não tem mais Etiquetas; .staff/.cstaff ganham 📄 Documentos) Resposta em CACHE por PAPEL (`var/nav-cache.<animeitor
/contest/problems?contest=<c> Bearer {problems:[{short_name,full_name,problem_id,has_statement_html,has_statement_pdf,statement_langs,has_samples?,function_langs?,time_limits,languages,author?}], statement_langs, default_statement_lang} (function_langs (2026-09-30) = as linguagens de submissão de FUNÇÃO do problema (FUNCTION_LANGS do pacote), do json servível como o has_samples — o editor com esqueleto (módulo esqueletos) abre vazio nelas; ausente quando o problema não vem do banco) (has_samples (2026-09-23) = há exemplo para BAIXAR — o samples do json servível do banco, sem os too_big; false com SAMPLE=no no conf do pacote ou sem tests/input/sample* (a página esconde o link Exemplos); AUSENTE quando o problema não vem do banco (enunciado enviado à mão) — aí o link fica, como antes. Ver PACOTE.md, "Problema sem exemplo") (statement_langs do envelope = os idiomas que a prova OFERECE (STATEMENT_LANGS do conf, admin/chefe em /contest/admin/statement-langs; ausente = AUTOMÁTICO: todo idioma que cada problema tem, e o envelope lista a união do que existe, pt sempre); default_statement_lang = o LOCALE do contest se oferecido, senão o 1º — é onde a sanfona abre; statement_langs de cada problema = oferecidos ∩ com arquivo enunciados/<skey>[.<lang>].html|pdf ou tradução no banco, materializada aqui na 1ª vez como o PT — a sanfona mostra chips só quando >1) (author = crédito de quem escreveu, do arquivo author do pacote; só sai depois do fim para todas as sedes — ou p/ admin/juiz-chefe/juiz —, porque durante a prova o nome do autor é pista) (problem_id = forma canônica coleção#problema, igual ao treino — é o que o juiz usa p/ achar o pacote; time_limits = {lang:seg} do store, {} se o conf ocultar via SHOWTL=0 — com pool de juízes definido (override do problema em problem-judges.json → CONTEST_JUDGES do conf) o MAX é só entre os hosts do pool efetivo; languages = ids permitidos do problema: override por problema (problem-langs.json) → whitelist do contest (LANGUAGES) → default do próprio pacote (.moj-meta.json languages, servido no índice do treino) → [] (=todas) — o último elo faz um problema "só-pddl" restringir sozinho sem o contest configurar nada; com restrição, o front mostra um chip de TL por linguagem permitida — o TL medido dela ou o default — e omite o chip "padrão"; sem restrição, chips medidos + "padrão"). Gate de visibilidade (forçado pela API): .admin/.judge veem sempre; .staff nunca; usuário normal só após o início — antes disso retorna {problems:[], locked:"not_started"} (.staff → locked:"staff"), e o front mostra a tela de contagem regressiva. Resposta em CACHE (`var/problems-cache.<author
/contest/samples?contest=<c>&problem=<letra|problem_id> Bearer os exemplos do enunciado como dado `{problem,problem_id,samples:[{name,input,output}
/contest/statement?contest=<c>&problem=<letra|problem_id>&format=html|pdf&lang=pt|en|es Bearer UM enunciado, cru (text/html ou application/pdf; format default html). lang (default = default_statement_lang do contest): fora da allowlist → 400 lang_invalid; idioma que a prova NÃO oferece → 404 (como problema inexistente); oferecido sem arquivo → serve o PT (tradução ausente nunca é erro). Arquivo = enunciados/<skey>.<lang>.<fmt> › <skey>.<fmt>; ETag pelo arquivo resolvido; X-MOJ-Statement-Lang = idioma do arquivo servido. Gate IDÊNTICO ao da lista (can_see_problems): .staff/.cstaff nunca, competidor só depois do início, admin/juiz sempre — e a recusa é 404, não 403 (pedir o enunciado direto não pode confirmar que o problema existe antes de a prova abrir). A chave do arquivo sai sempre do PROBS do conf, nunca do parâmetro (problem=../x = 404). Responde ETag (mtime+tamanho) + Cache-Control: private, max-age=60 e honra If-None-Match com 304 — recarregar a página não repuxa MB, e enunciado corrigido no meio da prova invalida sozinho.
/contest/news · /contest/resources Bearer seções opcionais (vazias = ocultar). Notícia pode ter anexo {file:{name,size}}
/contest/news-file?contest=<c>&id=<news_id> GET Bearer
/contest/backup?contest=<c> GET/POST Bearer
/contest/backup-file?contest=<c>&id=<id>[&login=<l>] GET Bearer
/contest/print?contest=<c> GET/POST Bearer
/contest/print-file?contest=<c>&id=<id> GET Bearer
/contest/staff/queue?contest=<c> GET Bearer (.staff/.cstaff/admin)
/contest/staff/print-action?contest=<c> POST Bearer (.staff/admin; .cstaff não — 403)
/contest/staff/print-pdf?contest=<c>&id=<id> GET Bearer (.staff/admin; .cstaff não — 403)
/contest/badges?contest=<c>[&staff=<l>&include_disabled=1] GET Bearer (.cstaff/admin; .staff → 403 cstaff_required)
/contest/doc?contest=<c>[&type=<info-sheet|contest|times>&lang=<pt|en|es>&fmt=<pdf\ **Tipos**: info-sheet|contest|times|editorial. **Gate de FASE** (além do de publicação): p/ quem NÃO julga (organização = só admin/chief/judge; **.staff/.cstaff/.monesperam a fase como o time** — decisão de 2026-09-15: a sede não recebe o caderno antes da prova),contest/times publicados só aparecem/baixam **a partir do início** (contest_phase != before) e editorial só **depois do fim p/ TODAS as sedes** (contest_over_for_all— prorrogação segura);info-sheet` = publicado é visível. A LISTAGEM filtra igual (o time nem vê a linha). html>]` GET
/contest/admin/report-publish?contest=<c> GET/POST admin
/contest/admin/docs?contest=<c> GET/POST admin ou .cjudge
/contest/rounds?contest=<c> GET Bearer
/contest/round?contest=<c>&round=<slug>[&file=index.html] GET Bearer
/contest/updates?contest=<c>&news_since=&clar_since= Bearer resumo leve p/ polling de notificações: {news:{last,count,unread}, clar:{last,count,unread}} (clar = respondidas visíveis ao usuário; unread = date/answered_at > since)
/contest/history?contest=<c> Bearer TXT (submissões do usuário). O veredicto (campo 5) sai SEMPRE canônico (Accepted/Wrong Answer/… — lib/verdict.sh; veredicto manual com texto p/ o time sai como o TEXTO — canon_team), em todos os modos: a string de display com score/grupos fica no disco e o detalhe por modo vem do /submission/summary (redigido). Pendentes e strings desconhecidas passam intactos; o sufixo (Ignored) é preservado. O history em disco não muda
/contest/balloons?contest=<c> Bearer mapa letra/short→cor (default ICPC A–O) Resposta em CACHE (var/balloons-cache.json, sem variante — o mapa é o mesmo p/ todos). Sem teto de idade: as entradas cobrem 100% do corpo (balloons.json + a paleta padrão, que é código — o próprio handler entra como entrada, então um deploy invalida).
/contest/regions?contest=<c> Bearer regiões p/ filtro do placar (o filtro casa por nome — igualdade com a sede .team.region do time via /contest/teams — ou pelo regex no login)
/contest/virtual?contest=<c> — (contest secreto: Bearer do contest) GET → {available:true,url} ou {available:false} — a participação virtual dá p/ fazer AGORA? É o portão inteiro (vr_load); quem usa é o aviso do placar, que fica no subdomínio do contest e não tem o token do treino p/ perguntar ao /treino/virtual/info. Não diz o MOTIVO (o painel Evento › Virtual diz). 07/10/2026.
/contest/teams-meta?contest=<c> — regras regex→{country,school,school_full} {rules:[…]} — placar resolve bandeira/escola e filtra por país/escola (bandeiras locais em /shared/flags/). Fallback: só preenche o que o por-usuário (/contest/teams) não trouxe
/contest/teams?contest=<c> — (secreto exige sessão) ⚠️ com coortes, só os logins das coortes que o chamador pode ver (é o endpoint PÚBLICO que mais vazaria um convidado). diretório de TIMES por-usuário p/ o placar mesclar: {teams:{<login>:{univ_short?,univ_full?,flag?,region?,has_logo,has_photo}}} (o NOME vem do TXT do placar — fullname) — do .team do account.json + presença de logo.png/photo.png; só logins não-privilegiados com algo a dizer. Precedência no placar: isto > teams-meta (regex) > vazio
/contest/team-photo?contest=<c>&user=<l>[&thumb=1] — foto do time (thumb=1 = miniatura de 320px, ~7 KB, com cache longo — é o que a galeria do .animeitor usa). ⚠ Time sem foto NÃO dá mais 404: devolve a foto padrão do contest (200) com o cabeçalho X-MOJ-Photo: placeholder — é o que faz o Animeitor achar imagem para todo time. Quem precisa saber quem MANDOU foto usa o has_photo das listagens (lado máx 1000) — o placar não mostra isso (2026-08-24: "deixar simples"); quem cobra quem não mandou é a galeria do telão e o painel Pessoas › Times. Serve image/webp (formato de hoje) ou image/png (acervo antigo — ver lib/team-photo.sh). 404 só quando nem a padrão existe. Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/team-music?contest=<c>&user=<l> — música do time (audio/mpeg + Content-Length): a faixa que o telão toca quando ele resolve. Mesma doutrina da foto — time sem música NÃO dá 404: devolve a música padrão com X-MOJ-Music: placeholder; quem precisa saber quem MANDOU usa o has_music das listagens. Guardada como veio (mp3 validado por MIME, sem conversão — não há ffmpeg na imagem). Sem Range: o player toca progressivo. Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/placeholder?contest=<c>[&kind=photo|music][&thumb=1] — o 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, kind=music = a música. Escolhido pelo .animeitor; sem escolha, o de fábrica (server/etc/team-placeholder.webp / .mp3). Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/team-logo?contest=<c>&user=<l> — PNG do brasão do time (máx 128; célula do time no placar — vence o logo por regra do teams-meta). 404 sem brasão. Pública, inclusive em contest SUPER SECRETO (2026-08-24): a foto e a música existem para ir ao TELÃO, e o telão é um sistema EXTERNO (o Animeitor) que busca sem sessão — o gate protegia pouco e atrapalhava muito (nem o <img> do próprio MOJ funcionava: tag de mídia não manda Authorization). O SECRET continua trancando o que é dado de prova: score, teams, teams-meta, balloons e regions.
/contest/webcast?contest=<c>&key=<K> — (só a chave) ZIP do placar no protocolo do Animeitor (o mesmo do webcast.php do BOCA: contest/runs/time/version/icpc, campos separados por 0x1C). Rota SEM SESSÃO, de propósito: é o sistema Animeitor buscando em loop. A chave (criada pelo .animeitor) declara a visão de coorte servida; chave inválida/revogada → 404 (e linha em var/webcast-denied.log). O pacote vai SEMPRE descongelado — quem anima a virada é o Animeitor, que sabe a hora do freeze pelo lastmilescore. Cache com piso de 10 s. Formato inteiro em docs/WEBCAST.md
/contest/score?contest=<c> (Bearer opcional) TXT (1ª linha = modo, que pode trazer flags: icpc s = célula resolvida em SEGUNDOS desde o início, exibida pelos clientes como floor(seg/60); sem a flag = minutos, legado) — ver SCOREBOARD.md. Pré-início (regra: o placar nunca revela a quantidade de problemas antes de a competição começar): antes do CONTEST_START, quem não é is_judge recebe a vitrine (var/placar-prestart.txt — os times da visão pública com bandeira/sigla/nome e zero colunas de problema; build.sh <c> --prestart). Cache preguiçoso: (re)gera placar.txt (público, com freeze) e placar-full.txt (completo, sem freeze) se history/conf mudou. Privilegiados (.admin/.judge/.cjudge + allowlist SCORE_FULL_USERS — vale p/ liberar um .cstaff) com token recebem o completo; demais, o público. &view=<visão> escolhe a visão de coorte: public força a pública/congelada mesmo p/ privilegiado, oficial = só as coortes públicas, geral = tudo (só vale p/ quem já pode ver tudo) e o id de uma coorte pública com ranking (ex.: individual, times) devolve o placar paralelo dela — é público, não exige sessão, e a página /contest/score/?c=<c>&view=<id> abre direto nele. view=public — é o que a cerimônia de revelação (/contest/score/reveal.html, estilo ICPC resolver, nativa) usa p/ computar o delta frozen→full e revelar de baixo p/ cima; o botão "Descongelar tudo" da cerimônia = settings freeze:0 (só admin). &scope=mine (honrado só p/ .cstaff) recorta o TXT servido (frozen e full) aos usuários que o chefe de sede enxerga (staff-filters) — é a cerimônia por sede; fora da allowlist, o full só sai p/ o .cstaff quando o contest terminou para todas as sedes (contest_over_for_all: fim do conf + o maior end de time-overrides.json — sede prorrogada segura a revelação) e, com o telão do Animeitor em uso (evento publicado), depois que o .animeitor libera a revelação às sedes (reveal-release; site_reveal_open em lib/contest-gate.sh) — sem isso sai o congelado com X-MOJ-Frozen: 1. Na visão congelada (freeze em vigor) a célula não resolvida com resultado escondido sai tentativas/-? (ver SCOREBOARD.md). Em contest SUPER SECRETO (conf SECRET=1) o placar deixa de ser público: sem sessão daquele contest → 401 secret_login_required (idem balloons/regions/teams-meta). PLACAR ANÔNIMO (SCORE_ANON=1, 03/10/2026): quem não é da organização (admin, chefe, juiz, .mon, .animeitor — o conjunto da estatística) recebe JSON {anon:true, mode, n, guests, frozen, supported, problems, per_problem, dist, q:{p25,median,p75,max}} com X-MOJ-Anon: 1 (+ X-MOJ-Frozen), nunca o TXT; antes do início, só n. SCORE_FULL_USERS, coorte, view e scope=mine não furam. Gerado pelo build.sh (var/placar-anon.json).

Conta de placar / telão (.animeitor, o admin do contest — e a SEDE, recortada)

A sede entra recortada pelo staff-filters.json (o mesmo da fila/etiquetas/cerimônia): a listagem vem só com os times dela (scoped:true). O .cstaff usa photos, photo, music, photos-zip e o GET de placeholder — escrever em time de fora dá 403 staff_scope. O .staff é somente leitura: só photos e o GET de placeholder (photo/music/photos-zip → 403). Nenhum dos dois troca o padrão (POST 403) nem vê as chaves do webcast (403). | Rota | Método | I/O | |---|---|---| | /contest/animeitor/photos?contest=<c> | GET | galeria: {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}} (conta de papel fora). UMA varredura (find -printf + find\|xargs jq) para foto e música — com 1000 times são 0,1 s; um jq por conta levava 5,3 s. Para a sede (.cstaff/.staff) a lista vem recortada nela (scoped:true; +0,05 s da 2ª varredura do staff_visible_logins) | | /contest/animeitor/photo?contest=<c> | POST | {login\|filename, file_b64} sobe/troca a foto (convertida p/ webp, máx ~8 MB) · {action:"delete", login} remove. login aceita NOME DE ARQUIVO (fulano.jpg → fulano), que é como o envio em lote funciona. Auditado (animeitor-photo); toca .score-dirty. .cstaff só na própria sede (403 staff_scope). ⚠ diferente do admin/team-assets, não recusa contest com USERS_FROM (a foto é asset local) | | /contest/animeitor/music?contest=<c> | POST | {login\|filename, file_b64} sobe/troca a música do time · {action:"delete", login} remove. MP3 validado pelo MIME (file --mime-type = audio/mpeg; extensão não basta) → 400 music_bad; máx 15 MB (413 file_large). Corpo lido em ARQUIVO (read_body_file). .cstaff só na própria sede (403 staff_scope). login aceita NOME DE ARQUIVO (fulano.mp3 → fulano), que é como o envio em lote funciona. Auditado (animeitor-music) | | /contest/animeitor/photos-zip?contest=<c> | GET | ZIP do telão: fotos/<login>.webp para todos os times (quem não mandou foto leva a padrão) + musicas/<login>.mp3 só de quem mandou + placeholder.webp e placeholder.mp3 na raiz + teams.csv (login,nome,universidade,coorte,bandeira,foto,padrao,musica,musica_padrao — padrao/musica_padrao true = está com o padrão). A música padrão não é copiada por time: 5 MB × 1000 times viraria um pacote de gigabytes. Para o .cstaff o pacote sai recortado na sede dele | | /contest/animeitor/placeholder?contest=<c> | GET/POST | o padrão do contest: GET → {custom,bytes,mtime, music:{custom,bytes,mtime}} (o topo é a FOTO — contrato antigo); POST {file_b64} troca a foto (webp 1000px + miniatura), {kind:"music", file_b64} troca a música (mp3, máx 15 MB); POST {action:"reset"[,kind]} volta à de fábrica. kind fora de photo\|music → 422 kind_invalid. Auditado (animeitor-placeholder). A sede (.cstaff/.staff) faz só o GET — o padrão é do contest inteiro (POST → 403) | | /contest/animeitor/api?contest=<c>[&proposal=1][&links=1] | GET | API do Animeitor (o MOJ EMPURRA o contest p/ o telão — ANIMEITOR.md; só .animeitor/admin, .cstaff/.staff 403): {configured, has_cred, cred_source:contest\|moj\|none, moj_cred, default_url, user, url, event, moj_base_url, enabled, feed:{clock_s,runs_s,verify_s}, secret_contest, contests (os placares salvos; null= ainda vale a proposta), reveal:{released, at, by}, managed:{event, contests:[{name, sites[]}]}, status:{published_at, runs:{at,total,last_sent,added,updated,ignored}, last_error:{at,where,http,message}}, clock:{at,time_seconds,http}, verify (a última conferência, sem ids — ververify abaixo), feeder_alive_at, now} — o token nunca volta. event = o nome EFETIVO do evento: com a chave do MOJ, evento novo começa com moj- (o MOJ põe o prefixo no padrão e no digitado; evento que este contest já criou segue com o nome dele). Chave do MOJ ($ANIMEITOR_CRED_FILE, run/secrets/animeitor.cred): vale p/ todo contest sem chave própria, SÓ no servidor padrão (default_url) — cred_source:"moj", e nem o usuário dela volta (user é só o da chave PRÓPRIA do contest). proposal=1 → + proposal{teams_total, contests:[{name, source:{kind:view\|region, id}, n, codes[], kind:regex\|list, sites:[…]}]} (geral + coortes + países; sedes = folhas do regions.json; codes = o regex existente quando reproduz o recorte do MOJ, senão lista exata). links=1 → + links{public:[{contest,url}], revelation:[{contest,site,url}], sites:[{contest,site,whole,recipients[]}]} buscados AO VIVO no Animeitor — os de revelação são credencial (mostram as respostas depois do freeze); sites = a PRÉVIA de quem recebe cada link quando o reveleitor for liberado (.cstaff/.staff pelo staff-filters.json; whole = a sede de todos os times, só da organização); a leitura vai ao audit (animeitor-links-read) | | /contest/animeitor/api?contest=<c> | POST | {action}: config {url?, user?, token?, event?, moj_base_url?} (URL só https:// — 422 url_invalid; token write-only em secrets/animeitor.cred 600 = a chave PRÓPRIA, que vence a do MOJ; user:"", token:"" apaga a própria e volta p/ a do MOJ; sem aspas/espaço — 422 token_invalid; event igual ao nome que JÁ vale = sem mudança (a tela manda o nome resolvido a cada gravação; sem isto o padrão <contest>-<rodada> congelava e a rodada nova não criava evento novo); o nome EFETIVO que o evento terá — URL, nome e chave do mesmo pedido; com a chave do MOJ ganha moj- — é conferido antes de gravar qualquer coisa: passou de 64 = 422 event_invalid; mudou com o alimentador ligado = 409 feeding; liga o módulo telao) · test → {ok, events, event_exists, managed, cred_source} (502 upstream_unauthorized/upstream_error) · save {contests:[{name, source, codes\|null, ouro, prata, bronze, style, sites:[{name, source, codes\|null}]}] \| null} (codes:null = automático; manual sem regex = 422 codes_missing; regex vazia [] = 422 codes_empty; nome repetido = 422 name_duplicate; source.kind:"whole" = a sede de TODOS os times do placar — o link dela só vai à organização) · publish {adopt?} → {result:{ok, event:{name, action:created\|updated\|unchanged\|error, http, error?}, contests:[{name, action, error?, sites:[{name, action, error?}]}], deleted[]}} — idempotente por hash, sempre POST e no 409 PATCH, nunca PUT (o PUT do serviço zera o salt ⇒ troca os links de revelação); evento que já existe lá e não foi criado por este contest = 409 event_exists (só adopt:true assume); nome de evento que o registro (run/animeitor/events.json) diz ser de OUTRO contest do MOJ = 409 event_taken antes de qualquer request (com ou sem adopt; a mensagem não diz de qual); com a chave do MOJ, adopt de evento que ninguém do MOJ criou = 409 adopt_forbidden (com chave própria pode); com a chave do MOJ, evento NOVO sem o prefixo moj- = 409 event_prefix_required antes de qualquer request · verify → {verify:{at, event, state:ok\|diverge\|not_started\|no_sites\|error, ok, over, pending, final, final_at, runs, checked, uncovered, missing, wrong, extra, repair, sites:[{contest, site, http, expected, got, missing, wrong, extra, same_as?}], error?}, sample:{missing, wrong, extra} (ids, até 20), before?:{missing, wrong, extra}, runs?} — CONFERE, sede a sede, se o Animeitor tem todas as runs (rota pública runs_secret com a chave de cada sede, tirada do link de revelação ao vivo e nunca gravada): compara id, time, problema, tempo e resposta REAL; o que falta ou diverge (e a run que só existe lá, que vira X) é reenviado NA HORA e conferido de novo (before = o que a 1ª achou); sede com o mesmo regex em placares diferentes = uma consulta; final = tudo bate, a prova acabou p/ TODAS as sedes e nada pendente (o "validado"); antes do início not_started; grava var/animeitor-verify.json (409 not_configured/not_published) · push-runs {full?} → {runs:{total, sent, added, updated, ignored, removed, http?, error?}} (só o delta; id inteiro estável por submissão; removida no MOJ vira X; 409 not_published antes de publicar) · reveal-release / reveal-recall (interruptor ÚNICO que libera/recolhe os links de revelação p/ as sedes — ver /contest/animeitor/reveal; liberar exige evento publicado) · start / stop (marcador do alimentador daemons/animeitor-feed.sh: relógio 1 s + runs 2 s) · reset {confirm:"<evento>"} apaga o evento LÁ (422 confirm_required; 409 not_managed p/ evento alheio; libera o nome no registro). Tudo auditado (animeitor-*) | | /contest/animeitor/reveal?contest=<c> | GET | Reveleitor da SEDE (.cstaff, .staff; .animeitor/admin veem todos): {released, scoped, sites[], links:[{contest, site, url}], verify} — verify = a última CONFERÊNCIA (ver verify na rota acima): p/ .animeitor/admin o resumo do evento; p/ .cstaff/.staff só o das sedes DELE, {at, state, final, final_at, ok, sites:[{contest, site, ok}]} (o "validado" da sede: final exige a conferência final E todas as sedes dele batendo). Para .cstaff/.staff só existe depois que o .animeitor libera (reveal-release); antes → {released:false, links:[]} sem tocar o servidor do telão. .animeitor/admin recebem todos os links liberado ou não (released diz o estado; é a prévia deles) e a leitura vai ao audit (animeitor-links-read). A sede de todos os times (whole) nunca vai ao staff. Cada conta recebe só os links da sede DELA (staff-filters.json → staff_regions), em TODOS os placares em que a sede aparece; fail-closed: sem sede definida = scoped:false e zero links. O link mostra as respostas depois do congelamento (credencial): buscado ao vivo, nunca gravado, leitura auditada (animeitor-reveal-read). Competidor/juiz → 403 | | /contest/animeitor/webcast?contest=<c> (legado: pacote BOCA) | GET | {keys:[{id,key,view,label,created_by,created_at,revoked_at,fetches,last_at,last_ip}], views:[{id,name}], url_path, contest} — a chave aparece em claro (é o que se copia p/ o Animeitor) | | /contest/animeitor/webcast?contest=<c> | POST | {action:"create", view, label?} → {key, view} · {action:"revoke", id}. Visão inexistente → 422 view_invalid. Auditado (webcast-key) |

Admin do contest (logado como .admin daquele contest)

Rota Método I/O
/contest/admin/config?contest=<c> GET {name,mode,start,end,letters[],colors,regions,teams_meta,basic:{locale,login_start,login_enabled,freeze}} (no POST, basic.locale fora de pt|en|es = 422 locale_invalid ANTES de qualquer escrita — antes era descartado em silêncio)
/contest/admin/config?contest=<c> POST {colors?,regions?,teams_meta?,basic?} → grava balloons.json (escritor único cc_balloons_write; {} não mexe, null apaga)/regions.json/teams-meta.json + vars basic no conf (vazio = reseta). regions é gravado INTEIRO (lista de {name, regex?, subregions?, view?}); forma errada ou regex fora do subconjunto seguro = 422 regions_invalid antes de qualquer escrita (cc_regions_ok; com error.nodes:[{i,path,name,regex,err}] quando é a regex — códigos em lib/regions.sh); []/null = remover as sedes. A criação (/treino/contest-create/create) aplica a mesma checagem. basic.freeze:0 (descongelar) só a partir do fim geral + 1 min (409 freeze_locked, ver /contest/admin/settings). Resposta {saved, balloons_recolored, balloons_printed_old}: gravar/apagar as cores passa as tarefas de balão AINDA NÃO impressas à cor nova (meta + PDF em cache refeito) e conta as já impressas e não entregues (papel na cor antiga) p/ a tela avisar
/contest/admin/users?contest=<c> GET {users:[{login,fullname,email,admin,disabled,disqualified,shared,dir_only?}],shared} (sem senha). Contest COMPARTILHADO (shared = a fonte): shared:true = entra pela conta do treino (sem senha local); os participantes que só têm DIR (entraram/submeteram sem conta local) vêm com dir_only:true e o nome lido na fonte por caminho (nunca varrendo o treino)
/contest/admin/user-add?contest=<c> POST {login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?} → adiciona/reseta, devolve a credencial. fullname é o nome do time (campo único — usuário de contest É o time); os campos de TIME mesclam no .team do account.json Conta que já existe (reset de senha; 03/10/2026): tira a marca disabled e DERRUBA as sessões abertas dela (o token antigo seguia valendo). Nome com : (04/10/2026): gravado com ∶ (U+2236, igual na tela — o placar e o relatório são TXT separados por :; name_clean em lib/common.sh, o MESMO em todo caminho que grava nome) e a resposta traz adjusted:[{login,field:"fullname",from,to}]; senha/email com : = 422 colon.
/contest/admin/users-bulk?contest=<c> POST carga em lote {users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}], on_existing?:skip|update} (default skip; ≤5000; senha vazia = gerada). fullname é o nome do time (campo único); os campos de TIME (opcionais) gravam o .team{univ_short,univ_full,flag,region} — carga única de credenciais+país+sede+universidade (a UI aceita CSV com cabeçalho: login,senha,nome,pais,sede,univ,univ_nome, ordem livre — time/equipe são aliases de nome). update: senha vazia = regenerada (semântica de reset em massa); nome/email só sobrescrevem se vierem na linha (linha parcial de enriquecimento — ex.: login+sede — não clobbera o nome do time) e os campos de time mesclam; conta privilegiada existente (.admin/.judge/.cjudge/.staff/.mon) nunca é tocada (skip privileged); criar privilegiada nova é permitido. Nome (e escola) com : = gravado com ∶ e listado em adjusted; senha/email com : = pulado (colon). → {created:[{login,password,fullname,email}],updated:[…],skipped:[{login,reason:login_invalid|colon|exists|privileged|duplicate|write_failed}],adjusted:[{login,field:"fullname",from,to}],counts:{created,updated,skipped,adjusted}} — o motivo é ESPECÍFICO p/ a tela dizer por que cada linha ficou de fora (antes era um invalid genérico e a tela só contava). Auditado users-bulk
/contest/admin/teams?contest=<c> POST identidade do time por conta (aba Times): {set:{<login>:{fullname?,univ_short?,univ_full?,country?,region?}}} (fullname vazio = não mexe; "" nos campos de .team apaga; login inexistente = skipped) → {saved, skipped:[login], adjusted:[{login,field:"fullname",from,to}]} — nome/escola com : = ∶ (sede/bandeira, que são chaves, = espaço) · {action:"materialize"} → {materialized, filled} (teams-meta/regions nos campos VAZIOS). USERS_FROM = 409 shared_users. Auditado teams-set/teams-materialize
/contest/admin/user-remove?contest=<c> POST {login} → remove (mv p/ .removed-users/, dados preservados; toca .score-dirty — o placar o esquece sozinho; não pode remover a si mesmo); contest COMPARTILHADO: deixa um TOMBSTONE local (senha !… autoritativa + desclassificado) — a pessoa não volta pela conta do treino e não aparece no placar; vale até p/ quem nunca entrou TIME de inscrição (03/10/2026): não move o dir — fica uma LÁPIDE (disabled + disqualified + removed_at; o membro não entra nem como ele mesmo) → {removed, login, team:true} (antes: 500 depois do mv e o membro voltava sozinho).
/contest/admin/user-disable?contest=<c> POST {login, undo?} → desabilita (senha vira !…, derruba as sessões; conta de papel = 403). Participante COMPARTILHADO (antes 404) ganha um overlay local com a senha !… (autoritativa); {undo:true} reabilita o compartilhado (volta a entrar com a senha do treino) — conta própria reabilita com senha nova (user-add; undo = 409 use_user_add). Quem nunca entrou = 404 (use user-remove) TIME de inscrição (is_team, 03/10/2026): a senha do time é sempre !<uuid> e o membro entra pelo alias com a dele — desabilitar grava a marca disabled:true (toda conta ganha a marca; o login do membro de time desabilitado = 401 bad_creds), undo a tira. A listagem /contest/admin/users usa a marca (time não aparece "desabilitado" pela senha !<uuid>) e traz is_team.
/contest/admin/logout-all?contest=<c> GET/POST admin
/contest/admin/users-set-password?contest=<c> POST {password, include_disabled?} → troca a senha de todos os não-privilegiados. Contest COMPARTILHADO = 409 shared_users (a senha é a do treino; contas próprias = conversão)
/contest/admin/registrations?contest=<c> GET/POST admin (GET também .cjudge)
/contest/admin/regions?contest=<c> GET/POST admin (GET também .cjudge)
/contest/admin/users-convert?contest=<c> POST admin
/contest/classification?contest=<c> GET público (gate de secreto igual ao placar; sessão OPCIONAL)
/contest/admin/classify?contest=<c> POST/GET admin
/contest/admin/modules?contest=<c> GET/POST admin
/contest/admin/esqueletos?contest=<c> GET/POST admin
/contest/admin/user-disqualify?contest=<c> POST {login, undo?} → DESCLASSIFICA (.disqualified=true no account.json): a conta continua existindo/logando, mas some do placar (sc_users) e da estatística (stats-gen pula o login por inteiro — placar e estatística sempre contam a MESMA população). undo:true reverte. Não mexe em senha/sessões (desclassificar ≠ desabilitar). Participante COMPARTILHADO (antes 404) recebe a marca num overlay local. Auditado user-disqualify

Reusa os editores de web/shared/contest-config/ (os mesmos da criação). Bandeiras locais/offline em /shared/flags/ (271 países + 27 estados); GIFs do Sonic em /shared/assets/sonic/. USERS_FROM=<contest> no conf faz o login cair no passwd compartilhado (ex.: treino), mantendo o .admin próprio.

Admin / Judge / Ops (Bearer + papel)

Rota Método Papel Ação
/contest/allsubmissions?contest=<c> GET admin/chief/judge/mon TXT 9 campos (tempo:username:problemid:lang:verdict:epoch:subid:fullname:univ). Admin/chief = completo. .judge puro e .mon = ANÔNIMO: campos 2 (username), 8 (fullname) e 9 (univ) vazios, aridade mantida, linhas ordenadas por epoch (o corte é na API — curl não descobre quem submeteu; anonimato é só desta rota)
/contest/final-verdicts?contest=<c> GET/POST GET=judge; POST=admin/chief opções de veredicto manual com 3 campos (2026-09-14): label = o que o JUIZ escolhe (≤80); verdict = CLASSE canônica, uma das 6 de lib/verdict.sh (Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, Compilation Error) — é o que pontua/penaliza/colore (422 verdict_invalid fora das 6); team = texto que o TIME vê (≤60, sem :/¦; vazio = a classe; Accepted nunca leva team). GET → {verdicts:[classes], classes:[as 6], options:[{label,verdict,team}]} (arquivo antigo com verdict fora das 6 é lido como classe Wrong Answer + team = a string antiga). O history recebe classe¦team (rv_canon_verdict); quem fala com o time usa canon_team/vteam, quem pontua usa canon/vcanon. Default = as 6 (6-Contact staff = Wrong Answer + team). Auditado (final-verdicts-set) Opção inválida (rótulo fora de 1..80, texto do time com :/¦ ou >60) = 422 option_invalid com option — antes era descartada calada (03/10/2026).
/contest/auto-verdicts?contest=<c> GET/POST GET=judge; POST=admin/chief O que vai para revisão no veredicto manual (contests/<c>/auto-verdicts.json; regra em lib/review-rules.sh). v2 = OPT-OUT (25/09/2026): com MANUAL_VERDICT=1 tudo sai AUTOMÁTICO, menos o marcado — {version:2, review:{"<cid>":[classe…]}, langs:[{lang, problem:"*"|cid, verdicts:[classe…], to:"review"|"auto"}]}; a exceção de linguagem vence a grade, a do problema vence a de "*", empate = revisão; arquivo ausente = tudo automático; ilegível = tudo em revisão; classe fora das 6 (erro do juiz) = sempre revisão. O v1 (formato anterior, opt-in {"<cid>":{"<lang>|*":[classes automáticas]}}) vale como sempre até alguém salvar pela tela nova. GET → {version:2, state:missing|invalid|v1|v2, rules:{review,langs} (v1 convertido), items:[{id,letter,title}] (ordem da prova), verdicts:[6], langs:[do contest, canônicas], manual_verdict, releasable, problems:[cid] e matrix (legado)}. POST {rules} → grava v2 saneado (só cids do contest e as 6 classes; py3→py; linguagem fora de ^[a-z0-9_+.-]{1,20}$ = 422 lang_invalid) → {saved, rules, releasable}; POST {action:"release"} → libera com o veredicto COMPUTADO os itens da fila sem voto e sem conflito que as regras atuais mandam automáticos (sob o lock da fila) → {released, left} (auditado review-auto-release); POST {matrix} (cliente antigo) grava v1. releasable = quantos retidos (sem voto/conflito) as regras soltariam — a tela oferece "Liberar agora", não libera sozinha. Auditado (auto-verdicts-set)
/contest/review/list?contest=<c> GET judge fila de revisão manual {manual, options, items:[{id,login(**admin/chief**; null p/ juiz comum — anonimato),problem_id,lang,computed_verdict,status,conflict,created_at,claimants:[{by,elapsed_s,expires_in_s}],votes_n,my_vote,votes(**admin/chief**; oculto p/ juiz comum — anti-anchoring)}], counts:{not_evaluated,being_evaluated,awaiting_second,conflicts}, my_active, quorum} — quorum = nº de juízes que validam cada veredicto (conf REVIEW_JUDGES, 1..5, default 2); awaiting_second = com voto(s) mas abaixo do quórum
/contest/review/claim?contest=<c> POST judge {id,action:claim|extend|giveup} — máx 2 avaliadores, 1 ativa por juiz (409 already_evaluating/slots_full), TTL 5 min (extend=+5). Rejeita quem já votou (already_voted). Auditado (review-claim/extend/giveup)
/contest/review/vote?contest=<c> POST judge {id,label} — registra o voto (permanente) e libera o juiz (sai dos avaliadores → pode pegar outra); rejeita voto repetido (already_voted). 2 iguais → libera ao aluno (enfileira setverdict, review-agree); 2 diferentes → conflict (review-conflict)
/contest/review/resolve?contest=<c> POST admin/chief {id,verdict} (label ou classe da lista) — o juiz-chefe resolve o conflito; libera ao aluno com classe¦team quando a opção tem texto. Auditado (review-resolve)
/contest/review/conflicts?contest=<c> GET admin/chief sumário dos conflitos {conflicts:[{id,login,problem_id,lang,sub_epoch,computed_verdict,votes:[{by,label,verdict}]}], n, options} (lang/sub_epoch p/ abrir log + código na resolução) — lista p/ a aba de Conflitos do chefe; o alerta global usa a contagem do /contest/staff-alerts
/contest/staff-alerts?contest=<c> GET judge/chief/admin/.mon contagens p/ o alerta GLOBAL da organização (banner + som + (N) no título, em qualquer página — shared/staff-alert.js, ligado pelo auth.status): {now, clar:{open, unclaimed, last}, review:{manual, quorum, needing, mine_todo, mine_last, conflicts}|null}. unclaimed = aberta sem reserva ou com reserva vencida; last = a pergunta aberta mais nova; mine_todo = itens que ESTE login ainda pode votar (a regra do review/claim); needing/mine_todo só com MANUAL_VERDICT=1; conflicts = o n do review/conflicts, só p/ chief/admin (null p/ o juiz). .mon: review:null. Só números — nenhum login de quem perguntou ou votou. Servida pelo porteiro (r_staff_alerts, ~1 ms, sem fork; molde-route.sh add contest/staff-alerts); a aba LÍDER de cada navegador (Web Locks) consulta a cada 8–12 s (30–40 s com todas ocultas) e conta às outras por BroadcastChannel. Time: 403 staff_alerts_forbidden
/contest/review/stats?contest=<c> GET admin/chief estatística por .judge (do admin-audit.log) {judges:[{judge,votes,avg_response_s,timed,agreements,conflicts}], total:{votes,avg_response_s}} — nº de veredictos, tempo médio claim→voto, concordâncias e conflitos; alimenta a aba Situação do juiz-chefe Concordância/conflito contam p/ TODOS os votantes (voters= no audit desde 03/10/2026); o total deixou de vir zerado.
/contest/set-verdict POST admin ou juiz-chefe {contest,problem_id,verdict,username} — override direto (modo legado/auto-resposta); verdict = label OU classe da lista configurada (rv_canon_verdict; string livre = 422 verdict_invalid desde 2026-09-14); consumido pelo daemon (setverdict) e finalizado pelo escritor único
/contest/rejudge POST admin/chief {ids:[…]} — RE-JULGA cada submissão: reconstrói a fonte arquivada + metadados do history e a reinjeta no spool (shard do dono) como submit com o MESMO id; a linha vira pendente só DEPOIS de o spool estar gravado e conferido. → {queued:[ids], count, skipped:["<id>:<motivo>"], skipped_count} (motivo sem_history | sem_fonte | fonte_vazia | spool_falhou). A fonte vai ao jq por arquivo (--rawfile) e as listas da resposta por ok_json_slurp — fonte >~96 KiB virava spool de 0 byte (Judge Error) e milhares de ids davam 500 build_fail (01/10/2026; smoke-contest-rejudge.sh)
/admin/adduser POST admin {contest,login,fullname,email?,password?} (gera senha)
/admin/passwd POST admin {contest,login,newpass}
/admin/contest/extend POST admin {contest,end_epoch}
/admin/synctreino POST admin sincroniza treino
/admin/rejudge POST admin {ids:[…]} ou {contest,problem}
/ops/queue GET admin tamanho da fila por contest
/ops/problemtl?problem=<p> GET admin time limits do problema
/ops/updateproblemset POST admin {repo}
/ops/alerts GET/POST bot POST {ack:[{id,ok,error}]} = o bot confirma as entregas do poll anterior (2026-09-14): item .json sai do outbox p/ run/alerts/inflight/<id>.json no claim e VOLTA ao outbox se não houver ack em ALERT_INFLIGHT_TTL (600 s); o ack do relatório de quartil é o que marca sent (rel_ack; ok:false grava last_auto.outcome="failed: …" e o quartil segue devido). O .txt de incidente continua at-most-once. GET: avalia incidentes (juiz offline+fila, fila grande, daemon caído, job parado — queue_stuck: submissão na fila há ALERT_STUCK_AFTER=15 min com juiz online; a mensagem traz quantos, contest · problema da mais antiga, há quanto tempo e o motivo provável (memória, pool offline, largura, linguagem); lembrete no máx. a cada ALERT_STUCK_COOLDOWN=1 h —, bot fora do ar — bot_gone, que só enfileira a mensagem na VOLTA) com histerese/cooldown e drena o outbox: {items:[{id,text,chats:[<chat_id>…],loud,group}]} (no máx. ALERT_CLAIM_MAX=30 por poll — o Telegram corta acima de ~30 msg/s; o resto sai no poll seguinte). O bot só entrega (+ grupo, exceto quando group:false = mensagem dirigida a UMA pessoa; loud:true = com notificação). Efeito colateral: toca run/alerts/bot.alive (heartbeat do bot — vira o campo bot do /index/status e a linha 🤖 do /status/) e roda a varredura do convite de time pendente (inv_sweep_all, stamp próprio a cada INVITE_SWEEP_THROTTLE=300 s: manda o "último aviso" quando falta ≤ REG_REMIND_LEAD p/ a inscrição fechar) e o relatório de quartil (rel_sched_check, stamp próprio a cada RELATORIO_SWEEP_THROTTLE=3600 s: quartil do semestre vencido e não enviado ⇒ gera e enfileira o painel só para o grupo via alert_group — item {chats:[],group:true}, o único destino é o ALERT_GROUP_CHAT do bot). Estado em run/alerts/; sem cron (o poll do bot é o relógio)
/ops/relatorio POST bot painel de submissões p/ o grupo dos professores (comando /relatorio do mojinho). Body {telegram_id, args:[…], chat_id?, chat_type?} (de onde o comando veio; o bot manda desde 2026-09-14). aqui (só com chat_type group/supergroup; 422 not_group) grava chat_id em relatorio.json — o envio automático passa a ir SÓ para esse grupo (alert_dm com chats:[chat_id], group:false); sem chat_id cai no ALERT_GROUP_CHAT do bot (alert_group). refazer <k> desmarca sent[k..4] (o próximo sweep reenvia). status mostra destino e last_auto {k,at,outcome}. Admin ANÔNIMO no grupo (from.id = GroupAnonymousBot) recebe 403 anonymous_admin com a explicação. Trilha em run/alerts/relatorio.log; o sweep do /ops/alerts carimba o stamp DEPOIS do trabalho (falha = retenta em 10 min). o gate é PELO telegram_id: só conta .admin do treino com Telegram vinculado (o mesmo conjunto que recebe alertas; 403 admin_required). args: vazio = relatório do semestre configurado [inicio, agora] (409 not_configured/not_started) · AAAA-MM-DD = override pontual [data, agora] (400 bad_date) · config <ini> <fim> = grava o semestre em contests/treino/var/relatorio.json (quartis passam a ser enviados automaticamente pelo sweep acima; os JÁ vencidos entram pré-marcados — sem spam retroativo; 422 bad_date/bad_range) · status = config + quartis + enviados + próximo. Resposta {html} (Telegram HTML): top-10 de contests por submissões no período (treino em linha própria, privilegiados excluídos), usuários ativos, vs mesmo período do ano anterior e YTD vs anterior. Gerador score/relatorio-gen.sh (uma passada em todos os users/*/history), cache com TTL 600 s em var/relatorio-cache.json. Base fria: a geração síncrona tem orçamento de REL_SYNC_BUDGET=50 s (frio já mediu ~70 s no prod, quente ~3 s); estourou ⇒ termina em background e a resposta vem {html:"⏳…", pending:true} — repetir o comando em ~1 min serve do cache. O sweep de quartil usa cache PRÓPRIO (var/relatorio-cache-auto.json, janela de until fixo = imutável, exact-match sem TTL) e simplesmente envia no sweep seguinte

As rotas admin/* e ops/* (exceto ops/alerts e ops/relatorio, que usam bot-token) são consumidas pelo painel admin e pelo moj-cli. O mojinho-bot hoje é transporte fino: usa só treino/signup/*, treino/recover-password, ops/alerts e ops/relatorio (todos bot-token mojb_…), + /index/status (público).

Juiz (agente pull; Bearer mojw_<token> de worker)

Rotas que o agente (judge/agent/moj-agent.sh) usa. Protocolo e ciclo de vida em server/judge-gw/PULL.md. Todos os campos de LARGURA (24/09/2026) são opcionais; ausência = legado.

Rota Método Body / resposta
/judge/register POST {host, capability, arch, cpu, ncpu, mem_kb, gpu, problems:{id:cks}, langs[], toolchain, os, cage_root, cache_bytes, inv_hash, total_slots, partition, topology[], boot?, slot_cpus?, slots_by_node?:{<nó>:n}, smt?} → {registered, ttl, config:{partition,reserve,disabled,cfg_hash}}. boot:true re-enfileira o que estava atribuído ao host. slot_cpus (cpus do MENOR slot), slots_by_node e smt alimentam o claim por largura
/judge/heartbeat POST {host, state, inv_hash, free_slots?, total_slots?, cfg_hash?, status?:ok|draining|disabled, slot_cpus?, max_free_group?} → {assigned:[job…]|job|null, update, command, reregister, config?, holding?}. É o escalonador: lote de até free_slots slots; cada job leva test_cpus (=CPUNEEDED), same_numa, slots (grupos×k_slots), par_max (testes ao mesmo tempo concedidos) e par_cap (teto do problema); update/command de calibração levam test_cpus/same_numa/slots. Sem slot_cpus o juiz é LEGADO (só k=1, sem os campos). holding:true = juiz segurado p/ um job largo (não recebe trabalho novo até caber)
/judge/decline POST {host, reason, id} | {host, reason, reqid} | {host, reason, command} → {host, result:requeued|judge_error|notfound}. O agente devolve o que não conseguiu alocar (corrida entre o claim e os slots): job volta à banda com epoch novo e declined.<host> (o host o pula por 60 s; 3ª recusa = Judge Error pelo spool); calibração volta a pending; comando é reenfileirado
/judge/package-meta?id= · /judge/package?id= GET versão (checksum = pkg_version) e o tar.gz do pacote (cache do juiz)
/judge/tl-report · /judge/calib-report · /judge/update-report · /judge/result POST TL calibrado por host · log/reports/sols da calibração · fecha um update · resultado do julgamento (vai ao spool; o judged é o escritor único)
/judge/list GET (admin) registro dos juízes {judges:[{host,capability,ncpu,mem_kb,langs,problems_count,state,online,total_slots,free_slots,slot_cpus,slots_by_node,smt,partition,tl_summary}]}

Status do sistema (público)

Rota Método Auth I/O
/index/status GET — health: {queue:{total_pending,spool_queued,band_queued,lists[]}, judge:{online,total,busy,healthy,cpus_online,gpus_online}, alert:{no_judges}, daemons:{judged}, bot:{alive,last_poll_age_s}|null} (cache 20s) — base da página /status/. cpus_online = Σ total_slots×slot_cpus dos juízes online (as CPUs A SERVIÇO; agente antigo: ncpu). gpus_online conta SÓ juízes com GPU de compute comprovada (registro com vendor nvidia/amd, vindo de nvidia-smi/rocm-smi; adaptador de display/lspci não conta). daemons.judged = processo local (pgrep) ou heartbeat fresco em run/judged.alive (≤JUDGED_ALIVE_TTL, 120s) — no deploy podman a API e o daemon estão em containers diferentes e o pgrep nunca o veria. bot = saúde do bot de alertas (mojinho): mtime de run/alerts/bot.alive (tocado a cada poll do bot em /ops/alerts); alive = último poll ≤180s; null = instalação sem bot (não é incidente). Quando o bot fica >5 min sem polar e volta, alerts_evaluate (bot_gone) enfileira UMA DM aos .admin com o período fora do ar — o carteiro não avisa a própria morte, mas avisa a ressurreição

Criação de contest (treino)

Permissão: usuários .admin sempre podem; demais por lista do admin OU threshold de problemas resolvidos no treino (com denylist). O contest entra no ar imediatamente. Problemas vêm do banco público (bank_id), por ID (source+problem_id, p/ não-públicos) e/ou com enunciado custom (em cada item name — ou title — é OPCIONAL: sem ele o nome do problema no contest é o título do banco no IDIOMA DA PROVA — o locale do spec; sem tradução nesse idioma, o PT —, nunca o id; contests criados antes de 2026-09-18 sem name ganham o título na listagem /contest/problems, no idioma em que a sanfona abre; 03/10/2026) — manualmente ou sorteados por tag/dificuldade. Usuários: compartilhados do treino (users_from=treino; login pela conta do treino, via fallback de verify_password) ou próprios (users[], senhas geradas se em branco). O admin do contest é sempre criado (sufixo .admin garantido). Problema PRIVADO (no topo OU em modules.rodadas.rounds[].problems) que o criador não pode ver ⇒ 404 problem_denied sem listar ids (idem no duplicate, com o duplicador como sujeito). O spec unificado é VALIDADO como os painéis: id de módulo desconhecido, regex de coorte/prorrogação/gate que não compila (ou >200 chars), rodada com fim ≤ início / freeze fora da janela / kind fora de warmup|official|extra, visão de telão inexistente ⇒ 422 modules_spec_invalid; seção com on:false é ignorada (2026-09-15). Exige ao menos um problema — sem isso, 422 no_problems; para criar vazio e configurar depois mande allow_empty:true (booleano estrito; na web é o botão "Criar vazio", na CLI a flag moj contest create --empty). Acrescentar problemas depois não tem restrição (/contest/admin/problems, com o contest já no ar). | Rota | Método | Auth | I/O | |---|---|---|---| | /treino/contest-create/permission | GET | Bearer | {can_create,is_admin,is_superadmin,reserved_id_prefixes:["icpc"],reason,solved_count,threshold,in_allow,in_deny,allowed_modes,login,name}. is_superadmin = login está em SUPERADMINS do conf do treino; só ele cria contest com id icpc* (o create/duplicate respondem 403 id_prefix_reserved aos demais). | | /treino/contest-create/problems?q=&limit= | GET | Bearer+criador | autocomplete dos problemas que o criador pode usar: públicos + os privados a que tem acesso (dono, colaborador ou membro da org) {problems:[{id,title,titles?,tags,access:mine\|shared\|public,private}],mine,shared,total} (titles {pt,en,es} = os títulos do problema por idioma, só quando há MAIS DE UM distinto — enunciado traduzido; title segue o PT. São as opções de nome do assistente; 03/10/2026). Privados primeiro; statement vem de var/jsons-private/. /create recusa problema privado sem acesso (problem_denied) e auto-valida (enfileira index) os privados sem enunciado pronto — o contest mostra o enunciado assim que o juiz indexa (contest/problems faz fallback p/ jsons-private e cacheia) | | /treino/contest-create/tags[?include_private=1] | GET | Bearer+criador | tags do banco com contagem {tags:[{tag,count}],total}. Padrão: banco público; include_private=1 (opt-in, desligado por padrão) soma os PRIVADOS que quem cria (a sessão) pode usar (dono, colaborador ou membro da org — owners_visible_for), com privados vencendo o cache público (despublicado recém sai uma vez só); índice de owners quebrado com o opt-in = 503 index_unavailable | | /treino/contest-create/collections[?include_private=1] | GET | Bearer+criador | coleções do banco público com contagem {collections:[{collection,count}],total} (escopo ≠ /problems/collections, que conta sobre os problemas do login). include_private=1 (opt-in, desligado por padrão) soma os PRIVADOS que quem cria (a sessão) pode usar (dono, colaborador ou membro da org — owners_visible_for), com privados vencendo o cache público (despublicado recém sai uma vez só); índice de owners quebrado com o opt-in = 503 index_unavailable | | /treino/contest-create/draw?tags=&collections=&count=&match=any\|all&difficulty=any\|easy\|medium\|hard\|known&seed=&include_private= | GET | Bearer+criador | sorteia problemas por tag, coleção e dificuldade (filtros em AND; dificuldade = taxa POR USUÁRIO de lib/difficulty.sh: easy = muito fácil+fácil (≥70 % de quem tenta resolve), medium = 50–70 %, hard <50 %, unknown sem tentantes — cada item traz difficulty, user_rate, attempters, bucket), reproduzível por seed {problems[],candidates,drawn,seed,collections,private_included}; cada item traz titles? ({pt,en,es}, só com mais de um título — as opções de nome), private/access (mine|shared|public, p/ o selo 🔒) e has_statement (o json servível existe e é legível; privado sem ele SAI marcado false, não some — o wizard avisa "enunciado em geração"). Padrão: banco público; include_private=1 (opt-in, desligado por padrão) soma os PRIVADOS que quem cria (a sessão) pode usar (dono, colaborador ou membro da org — owners_visible_for), com privados vencendo o cache público (despublicado recém sai uma vez só); índice de owners quebrado com o opt-in = 503 index_unavailable. collections = array JSON url-encoded (nome de coleção é texto livre — pode ter vírgula/espaço); casa exato; inválido/ausente = sem filtro | | /treino/contest-create/genpass?n= | GET | Bearer+criador | N senhas legíveis (palavras-para-senha) {passwords[]} | | /treino/contest-create/create | POST | Bearer+criador | {id?,name,mode,priority?,start?,end,languages?,allow_empty?, admin:{login?,password?,fullname?}, (users_from? \| users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}]), problems:[…] (cada um {problem_id|bank_id, name?, letter?}: sem letter= a 1ª letra LIVRE; letra repetida, sem diferenciar caixa, = 422letter_dup; numa rodada de modules.rodadas= 422modules_spec_invalid), **modules?:{: true | {on?, …seção…}}** (**mode** = icpc(default) \|obi\|treino\|heuristic\|outro(só.admin, 403 mode_forbidden); inválido = 422 mode_invalid; **não muda depois da criação** — exporte o spec, edite e recrie. SPEC UNIFICADO — um JSON levanta o contest inteiro: sedes{regions,teams_meta,time_overrides}, baloes{colors,during_freeze}, coortes{cohorts}, maquinas{ua_gate,site_lock{enabled,grace},nutella_url}, rodadas{active,rounds}, documentos{config}, inscricoes{enabled,window{open,close,late_minutes,team_max,teams,warmup_open}}, telao{views[{view,label}]}→ chaves NOVAS de webcast,classificacao{algorithm,config}→ estágio em rascunho (ou{stages:[{id?,algorithm,config,name?,venue?,when?,chip?}]}, vários; motor pela allowlist + config pelo --checkdo motor); seção presente = módulo ligado salvoon:false; tipo errado = 422 modules_spec_invalid; SEGREDO nunca entra; **esqueletos:{langs:{:{mode:"custom",code}|{mode:"off"}}}** grava o esqueletos.json, e esqueletosligado comshow_editor:false= 422editor_required— vale p/ criar, duplicar e template), compat:colors?:{A:"RRGGBB",…,enableSonic?}, regions?:[…], teams_meta?:[{regex,country,school?,school_full?}]no topo continuam aceitos (ligambaloes/sedes), locale? (pt\|en\|es; outro valor = 422 locale_invalid, antes descartado em silêncio), tz? (fuso IANA → CONTEST_TZ, mesma validação do settings; 422 tz_invalid; antes de 03/10/2026 era descartado em silêncio; entra também no export/duplicate/template),login_start?,login_enabled?,freeze?, show_log?,show_editor?,show_tl?,allow_backup?,allow_print?,score_anon?,manual_verdict?,secret?,login_ua_substring?,score_full_users?,penalty_minutes?,penalty_verdicts?} → {contest_id,admin_login,admin_reused,admin_password,users[],users_from,url,scoreboard_url}. Paridade com o settings: os toggles/opções espelham /contest/admin/settings (grava só o não-default). languages aceita array de ids canônicos (normaliza como o settings) ou string legada. priority = prioridade no escalonador (prova/lista-privada/lista-publica; AUSENTE = lista-publica — o assistente web a EXIGE desde 03/10/2026, a API/CLI seguem com o padrão; super só o SUPER-ADMIN do treino — SUPERADMINS no conf do treino; o .admin comum leva 403 priority_forbidden; no duplicate de um contest Super por quem não é super-admin a cópia nasce prova). A prioridade de nascimento vai à auditoria do contest (priority) e à trilha central do treino (contest-priority … via=criação); depois muda em /contest/admin/settings (admin do contest) ou /treino/admin/contest-priority (super-admin). judges[] = pool de juízes do contest (→ CONTEST_JUDGES; entra também no template/export/duplicate). Por problema: languages[] (vira problem-langs.json), judges[] (vira problem-judges.json) e statement_pdf_b64/statement_pdf_file (além do HTML). Admin não é sobrescrito: senha digitada é respeitada; em modo compartilhado, se o <login>.admin já existe na fonte users_from ele é reutilizado — admin_reused:true, admin_password:null — mas SÓ o .admin do PRÓPRIO criador (<criador>.admin); o de outra conta sem senha = 422 admin_login_foreign (com senha, nasce uma conta LOCAL). O admin reusado é gravado em SHARED_ADMIN no conf: num contest compartilhado, conta de PAPEL do treino só entra se for ele ou um SUPERADMIN (ver /auth/login). Nomes (admin e users[]) com : são gravados com ∶ (name_clean); senha/email de usuário com : = 422 user_colon, senha do admin = 422 colon. modules.inscricoes ligado SEM users_from (contas próprias) = 422 requires_shared_users (vale p/ duplicar e template). virtual sem as condições (problema privado, secreto, placar anônimo, não ICPC, sem início/fim — o portão precisa do contest no disco): o contest é criado com o módulo DESLIGADO e a resposta traz modules_skipped:[{id,code,reason,message}] (vazio quando nada foi desligado; vale p/ duplicar, template e import). | | /treino/contest-create/template | GET | Bearer+criador | baixa template JSON completo (documenta todos os campos do create, incl. toggles/priority/users/visual) | | /treino/contest-create/import | POST | Bearer+criador | {tar_b64} (.tar.gz com contest.json + enunciados/) → cria | | /treino/contest-create/templates | GET/POST | Bearer+criador | templates nomeados por criador (treino/var/contest-templates/<login>.json). GET lista os meus (?name= → 1, senão 404). POST {op:save,name,(template{}\|from_contest,include_problems?)} | {op:delete,name} | {op:rename,name,new_name}. O spec salvo é relativizado + whitelist no servidor: datas viram duration/login_lead/freeze_before_end; nunca guarda usuários/senhas/id/datas absolutas; a seção modules{} entra sem o que é preso a data (rounds, active, time_overrides). from_contest: só dono do contest ou admin (senão 404). Limites: 20/usuário, spec ≤64KB | | /treino/contest-create/export?id=&full_statements=0\|1 | GET | Bearer+criador | baixa o spec JSON de um contest existente (formato do /create — round-trip). Traz a seção modules{} dos módulos LIGADOS com os dados reeditáveis (regions, cores, coortes, gate de UA, plano de rodadas sem as arquivadas, config de documentos sem published, janela de inscrição, views do webcast SEM chave, algoritmo/config da classificação — {stages:[…]} quando há mais de um estágio) — nada de regions/colors/teams_meta no topo. Gate: created-by + (dono ou admin) — senão 404. Nunca exporta passwd/users/senhas/submissões. Enunciados: default embute só o material exclusivo do contest (sem json público no banco); full_statements=1 embute tudo | | /treino/contest-create/duplicate | POST | Bearer+criador | {from, id?, name?, start?, end?, admin?, users?\|users_from?} → usuários NUNCA vêm do origem — nem o compartilhamento (users_from só se vier no body; antes o do origem passava calado) — e cria contest novo copiando conf+problemas+módulos do from (usuários/submissões nunca; enunciado custom copiado por arquivo; o PLANO de rodadas anda junto com as datas — mesmo delta —, prorrogações por sede não viajam, o webcast ganha chaves novas). Datas: start=agora, end=start+duração original; login_start/freeze relativos preservados; name default "Cópia de …". Gate do from = o do export (404). Origem com inscricoes e SEM users_from no body = 422 requires_shared_users (a cópia teria contas próprias; peça as contas do treino — na tela, a caixa "usar as contas do Treino Livre" da cópia fiel; na CLI, moj-contest duplicate --users-from treino). | | /treino/contest-create/mine | GET | Bearer+criador | contests criados por mim (owner==login + created-by) {contests:[{id,name,mode,created_at,start,end,problems_count}],total} (admin usa /treino/admin/contests p/ a lista completa) | | /treino/admin/contest-perms | GET/POST | admin | GET {perms:{threshold,allow[],deny[],allow_meta{},deny_meta{}}, allow_info:[{login,name,has_photo,by,by_name,at,note}], deny_info:[…], me} — a trilha de quem liberou/bloqueou e quando (2026-09-15). POST por ação: {action:"add",list:"allow"\|"deny",login,note?} (conta tem de existir no treino: 404 unknown_login; *.admin na allow: 422 already_admin; sai da lista oposta; carimba by/at), {action:"remove",list,login}, {action:"threshold",threshold}. POST legado {threshold,allow[],deny[]} (substituição total) segue aceito e carimba meta nas entradas novas. Resposta = a do GET + saved. | | /treino/admin/contests | GET | admin | contests criados pela interface que este admin pode ver (cc_contest_visible_to, 2026-09-15): super-admin (SUPERADMINS no conf do treino) vê todos (scope:"all"); .admin comum vê os seus e os de criadores sem papel de admin (scope:"admin") — nunca o contest de outro .admin. {contests:[{id,name,mode,owner,owner_name,owner_has_photo,owner_is_admin,created_at,start,end,problems_count,priority}],count,scope,me,is_superadmin} (priority null = não definida = lista-publica; a lista sai por arquivo — ok_json_slurp — porque cresce com o nº de contests) | | /treino/admin/contest-priority | POST | super-admin do treino | {contest, priority} (lista-publica|lista-privada|prova|super) → {contest, priority, previous, changed} — o ÚNICO caminho para dar ou tirar Super (passa na frente de toda fila). .admin comum/aluno = 403 superadmin_required; contest inexistente = 404; valor inválido = 422 priority_invalid. Grava CONTEST_PRIORITY e audita no contest (priority de=… para=… via=painel-treino) e na trilha do treino (contest-priority contest=…); mesma prioridade = changed:false, nada auditado. A banda vale para o próximo envio (o que já está na fila fica onde está) | | /treino/admin/contest-remove | POST | admin | {contest} → move p/ lixeira (só os criados pela interface); contest fora do escopo acima = 404 (não confirma a existência). duplicate/export do criador seguem o mesmo escopo. |

Ações auditadas (em treino/var/admin-audit.log): contest-create, contest-template, contest-export, contest-perms, contest-remove — além de news-*, logout-*, lock-user.

O CLI moj-contest (web/moj-contest, servido em GET /moj-contest; fonte em moj-cli/; moj contest … delega a ele) cobre estas rotas e as de /contest/admin/*: criação (spec/ template), templates nomeados, export/duplicate, settings, problemas (com sorteio por coleção), usuários, sessões, auditoria, remoção e os documentos da prova (docs ls|gen|get|publish|unpublish|cover|set|text — ls/get valem p/ QUALQUER conta do contest, então a sede (.cstaff) baixa o publicado pelo terminal, útil em rede isolada). Sessões: criação/reuso = token do treino (moj login); administração = token daquele contest (moj-contest login <cid>, conta *.admin do contest) — o corte de acesso é sempre o do servidor.

Ambiente de contest (subdomínio + admin do contest)

Acessado por <id>.moj.<base> (subdomínio): o nginx injeta CONTEST_HOST; a API só serve aquele contest (auth/contest/submit/submission) e o frontend redireciona o resto para /contest/. ⚠ Esse isolamento vale só para quem ENTRA pelo subdomínio — da máquina de prova, curl --resolve moj…:443:<IP> chega ao site base pelo mesmo IP; quem fecha isso é a trava de sede por IP (/contest/admin/site-lock, 403 site_locked). Login com gate opcional por substring de User-Agent (LOGIN_UA_SUBSTRING, só não-privilegiados). Papéis: .admin/.judge/.cjudge (juiz-chefe, herda juiz)/.staff/.mon.

Rota Método Papel I/O
/contest/admin/sessions?contest=<c> GET admin sessões ativas (sessions[]{login,name,ip,user_agent,login_at,mkey,multi_ip,multi_ua}) + alerta de UA/IP diferentes. mkey = chave de máquina (m:<machine_id>/<boot_id> do UA do mlinux, senão ip:<ip> — lib/session-index.sh). A varredura SEMEIA o índice de sessões por login quando ele ainda não existe (run/sessions/.idx/<c>/)
/contest/admin/access-log?contest=<c>&day= GET admin log de acessos (epoch/login/ip/UA) + alertas
/contest/admin/site-lock?contest=<c> GET/POST GET admin ou .cjudge; POST só admin trava de sede por IP (lib/site-lock.sh; conf SITE_LOCK=1, SITE_LOCK_GRACE s, default 3600). Com a trava, todo login de COMPETIDOR reivindica o IP de origem p/ o contest até CONTEST_END+grace — com rodadas, até o fim da ÚLTIMA rodada não arquivada do rounds.json + grace (a reivindicação do aquecimento cobre a oficial; 03/10/2026) (estado run/site-lock/<ip>, uma linha por contest); o router.sh responde 403 site_locked a qualquer pedido daquele IP a outro alvo (treino, índice, /problems, outro contest — inclusive sessão antiga), exceto conta de papel e auth/logout. É o que fecha curl --resolve da máquina de prova ao site base. Auditado: 1ª reivindicação de cada IP (site-lock-claim ip= login= until=) e cada bloqueio (site-lock-block ip= target= route= login=, teto 1/5 min por ip+alvo; o contador blocked sobe sempre). GET → {enabled, grace, claims:[{ip,until,first,last,logins,blocked,last_block,last_target,active}], blocks:[…do audit…], claims_audit:[…]}. POST {action:"set", enabled, grace?} · {action:"release", ip} · {action:"claim-seen"} (prende os IPs de competidor vistos na janela da rodada, aquecimento incluso). Painel: Pessoas › Sessões & anomalias (tabela + bloqueios) e a chave em Máquinas & gate › 🔒
/contest/admin/anomalies?contest=<c>[&round=<slug>] GET admin ou .cjudge anomalias de uso de máquina DURANTE a prova (lib/anomalies.sh; painel Máquinas › Anomalias). As de MÁQUINA (multi_session, machine_shared, sub_other_machine, switched, site_short) valem sempre que o UA do mlinux identifica a máquina (machines_identified, com ou sem gate — 03/10/2026); o ua_mismatch precisa do gate em enforce ou observe (gate.active; gate.enforcing = barra). Cada anomalia traz id (`kind
/contest/admin/machines?contest=<c>[&round=<slug>] GET admin ou .cjudge mapa de máquinas da rodada (painel Máquinas › Gate & trava; rd_machines em lib/contest-rounds.sh): time × IP × User-Agent do var/access.log, contando os logins desde a abertura do login da rodada (LOGIN_START_TIME; sem ela, 6 h antes) e nunca antes do fim da rodada anterior (07/10/2026 — antes cortava no início e quem logou às 13:29 numa prova das 13:30 sumia do mapa) → {round, prev_round, window:{start, end, logins_from}, by_login:[{login, name, region, logins, first, last, ips, uas, pairs, ua_expected, ua_match, ua_any, multi_ip, changed}], by_ip, uas, ua_suggestion, totals}. Rodada arquivada: o machines.json gravado na promoção.
/contest/admin/ua-gate?contest=<c>[&login=<l>] GET/POST GET admin ou .cjudge; POST set só admin gate de navegador por sede (lib/ua-gate.sh). GET → {gate:{mode,from_login,by_region,by_regex,exempt,fallback,single_session}, legacy, configured, has_rule, regions[], check?} (configured = existe ua-gate.json; sem ele o mode vem enforce só p/ o LOGIN_UA_SUBSTRING legado valer; has_rule:false = ninguém tem esperado). POST {action:"set", mode?:"enforce"|"observe"|"off", from_login?, by_region?, by_regex?, exempt?, fallback?, single_session?} · {action:"check", login}. observe (03/10/2026) resolve o esperado (painel, Central, ua_mismatch das anomalias) mas o login NÃO barra e a sessão única não vale; enforce barra (403 ua_gate). Ligar (enforce/observe) SEM nenhuma regra = 422 gate_no_rule.
/contest/admin/staff-filters?contest=<c> GET/POST admin escopo do staff por sede (print-requests/staff-filters.json: {<login .staff/.cstaff>: [regex do login | "region:<sede>"]}; vazio = vê tudo). POST {filters}; regex que não compila = 422 regex_invalid com regex (03/10/2026 — antes salvava e o staff passava a ver nada). Auditado staff-filters.
/contest/admin/audit-log?contest=<c>&since=&action=&user=&limit= GET admin feed unificado (trace no instante exato de cada evento) {events:[{time,who,kind,action,details}],count}. 4 fontes: admin (var/admin-audit.log), login (var/access.log), submit (1 por submissão, no sub_epoch do users/<login>/history), verdict (1 por correção, no finalized_at do users/<login>/results/<subid>.json — traz o juiz; who = o aluno). Cada submissão gera 2 entradas: a submissão (quando o aluno enviou) e o veredicto (quando o juiz respondeu); pendente = só a submissão. As 4 fontes viram NDJSON num temporário e saem numa passada de jq --slurpfile — nunca --argjson (o array do admin-audit sozinho passa dos 128 KiB de MAX_ARG_STRLEN) Resposta traz total (casados antes do corte) e truncated; o filtro user casa quem fez OU os detalhes (login=ana) (03/10/2026).
/hooks/nutella?contest=<c> POST sem Bearer — HMAC (X-NB-Signature: sha256=<HMAC-SHA256 do corpo cru>; segredo por contest em secrets/nutella-webhook.secret, gerado pelo webhooks-install) webhook do NutellaBoot 3 (alertas das máquinas mlinux). Corpo {event, image, at, delivery?, data:{mac, id, kind, detail?, vendor?, other_mac?, boot_id?, binding?:{user_id}}}. webhook.test (botão testar do serviço) → 200 {ok, ignored, test} sem registro. Repetição pelo delivery (dentro do HMAC) quando existe. 401 OPACO p/ tudo que não autentica (contest inexistente, sem segredo, assinatura errada, corpo adulterado, at fora de −1 h…+5 min — o at está no corpo assinado): a rota é pública e não é oráculo de existência. Depois da assinatura: image fora das sedes do contest = 404 image_unknown; MAC malformado = 422; corpo > 64 KiB = 413; evento que não é alert.raised/alert.dismissed/machine.rebooted/machine.offline/machine.online → {ok, ignored:true}; repetição (mesmo evento+alerta+máquina) → {ok, duplicate:true}. Alerta → {ok, logged:true, notified}: vai p/ var/nutella-events.log (JSONL: t, at, event, image, mac, mkey="m:"+md5(mac), id, kind, detail, vendor, other_mac, team, notified; team = elo publicado no login, senão o da última coleta) e aparece em /contest/admin/anomalies (events[] com kind:"machine_alert"; os machine.* viram kind:"machine_event", counts.machine_events, sem aviso por Telegram). notified = DM de Telegram ao DONO do contest — só alert.raised, só DURANTE a prova (início−1 h … fim), no máx. 1 por máquina+tipo e 10 por contest a cada 10 min. Não atende pelo subdomínio do contest (isolamento)
/contest/nutella?contest=<c> GET/POST admin/chefe · .cstaff/.staff (a própria sede) integração nutellaboot (máquinas mlinux; ver NUTELLABOOT.md). GET → {configured, url, key_kind:admin|service, images[], status, can_admin, scoped, data} — data = o cache da coleta, com sedes[] CORTADO na API ao escopo do staff (agregados global/by_node vão inteiros). Do NutellaBoot 3 em diante cada agregado pode trazer — tudo OPCIONAL, só com o agente novo do mlinux — health{agent_new,psi_*_sum,psi_n,oom_machines,oom_kills,idle_pts,idle_hi,skew_n,skew_bad,reboots}, psi_mem_max, model_tm{modelo:n}, alert_kinds{kind:n} e psi_sum/psi_n[/psi_max] em pressure/series; data.skipped[] = sedes que o serviço não devolveu nesta coleta; bind{enabled, published, queued, last_at, log{ok,noroster,image_unknown,retry,error}} = estado da publicação do vínculo máquina↔︎time no LOGIN (só contagens); webhook{installed, sites, events} = webhook de alertas instalado (installed = alguma sede com o id do nosso webhook em var/nutella-webhooks.json; sites = quantas) e quantos eventos já chegaram (o segredo é write-only); data.prestart:true = coleta feita ANTES do início — inventário da última hora (máquinas ligadas, sede × imagem), não a prova; &catalog=<image> = catálogo de comandos da imagem. POST {action}: config {url?, key?, images?, bind?} (admin; images é OPCIONAL também com chave de serviço desde 21/09 — o serviço lista as sedes pelo glob da chave; bind:false desliga a publicação do vínculo no login — NUTELLA_BIND=0; a chave — nb3s_ de serviço, a recomendada, ou nb3a_ de administração — é write-only em secrets/; images = ids das site-images do evento, obrigatório com chave de serviço, que não lista /site-images) · collect (admin; coletor destacado) · push-roster {force?} (admin) → {pushed, kept, failed, empty[]}: os times de cada imagem = os da SEDE (regra única de sedes, pelo nome) ∪ os do cache que são conta do contest; sede sem time nenhum vai p/ empty e NÃO recebe roster vazio · push-bindings (admin) → `{queued, batch:{sent,bound,failed,noroster}
/contest/admin/preflight?contest=<c> GET admin ou .cjudge checklist pré-prova (a lista da 🏁 Central): {checks:[{id, level:ok|warn|fail, label, detail, label_en, detail_en, label_es, detail_es[, action]}], summary:{ok,warn,fail}}. label/detail em PT, *_en/*_es as versões em inglês e espanhol (a Central escolhe pelo idioma da interface; o /contest/admin/finish usa o MESMO formato nos checks do encerrar evento); action = botão que a Central põe no item (warm_judges; apply_titles → POST /contest/admin/problems {action:"apply_titles"}). Item prob_names (warn, 03/10/2026): problema cujo NOME é o título do banco em OUTRO idioma, havendo título no idioma da prova (contest criado antes de o nome seguir o idioma, ou que trocou de LOCALE depois) — o detalhe lista letra nome → título; nome personalizado ou escolhido no Renomear não entra. Item regions (módulo sedes, 28/09/2026): warn com sede de regex recusada, sede gravada fora da árvore, time que parou num grupo/país ou time sem sede (o painel de sedes mostra quem); ok com as contagens. Item shared_users (warn, 28/09/2026): contest com USERS_FROM — login e senha vêm da fonte; aponta a conversão em Pessoas › Contas (no lugar do users, que contaria só os overlays). Item judges_warm (2026-09-25): juiz a juiz, se cada juiz ONLINE do pool efetivo de cada problema (problem-judges.json → CONTEST_JUDGES → todos; desabilitado no judges-config fica de fora) já calibrou a versão ATUAL — run/tl/<id>.json com o host na pkg_version do memo run/tl/<id>.pkv; tl_checksum do índice divergente = pacote mudou = frio. warn + action:"warm_judges" com os pares frios por juiz (h2: B, C · h1: D); warn sem botão quando só há pares aquecendo (calibrate na fila commands/<host>/ ou em execução updates/inprogress/<host>/); ok quando todos quentes. Juiz frio calibra na 1ª submissão, que espera (7,3 min na XIV Maratona UnB). O antigo aviso "problema fora do cache dos juízes" (inventário do registro, desatualizado entre re-registros) saiu. Não abre pacote (lib/judge-warm.sh)
/contest/admin/warm-judges?contest=<c> POST {} admin ou .cjudge 🔥 Aquecer juízes: manda um calibrate DIRIGIDO (o mesmo de /problems/request-calibration com hosts) a cada par juiz×problema frio do judges_warm — quente não recebe nada (o dirigido é FULL), aquecendo não é repetido; sob flock (dois cliques não duplicam; 409 warm_busy se o lock não sai em 20 s). → {sent:[{host,id,letter,cmdid}], before:{warm,warming,cold}}. Cada calibração ocupa um slot do juiz por alguns minutos: é p/ ANTES do início (a Central avisa quando a prova já começou). O núcleo é jw_warm (lib/judge-warm.sh), o mesmo do server/bin/warm-judges.sh, que roda SOZINHO (03/10/2026) em contest de PROVA (CONTEST_PRIORITY prova/super — lista fica com o botão): na promoção de rodada (destacado, by=promote:<login>) e pelo judged ~WARM_LEAD_S (900 s) antes do CONTEST_START, uma vez por início (var/.warm-prestart; by=auto-inicio; AUTO_WARM_JUDGES=0 desliga). Botão fixo também em Operação › Situação. Auditado warm-judges no contest. Não abre pacote — quem baixa e calibra é o juiz
/contest/admin/dashboard?contest=<c> GET admin situação ao vivo: {judges:{online,busy,total,queue_depth,assigned,pool[],list[]}, routing, submissions:{total,pending,pending_list[],max_wait_s,response:{avg_s,max_s,p50_s,p95_s},timeline[]}} (routing = shards do escritor, mesmo shape do /treino/admin/queue) (janela = últimas N submissões; pool = hostnames de CONTEST_JUDGES, [] = sem pool — o front marca ⭐ os hosts do pool e alerta pool offline) 03/10/2026: submissions.pending_list/pending/max_wait_s vêm do history INTEIRO (não só da janela de 500) e SEM os segurados na revisão manual; cada juiz traz status, total_slots e used_slots — judges.busy conta juiz com slot em uso (multi-slot) e juiz disabled não conta como online.
/contest/admin/settings?contest=<c> GET/POST admin POST ATÔMICO e só o que muda (03/10/2026): TODOS os campos são validados antes da 1ª gravação (um 422/409 não deixa nada gravado) e só o que difere do conf é gravado e auditado (changed:[] = nada mudou). locale: "" = automático (apaga o LOCALE: cada um vê o idioma do navegador); o GET diz locale_set. freeze: 0 com o freeze ainda no FUTURO é livre (só o freeze em vigor espera o fim + 1 min). languages aceita a grafia antiga (C CPP PY3, cc, h…) e a grava canônica. login_start: 0 (03/10/2026) APAGA a abertura própria do login (LOGIN_START_TIME sai do conf; o login abre no início da rodada) — a tela manda 0 quando o campo foi esvaziado. priority (01/10/2026): o GET traz priority (efetiva; ausente = lista-publica), priority_set (o conf a define) e priority_locked (= está em super); o POST aceita lista-publica|lista-privada|prova — super = 403 priority_forbidden, contest em Super = 403 priority_locked (só o super-admin muda, em /treino/admin/contest-priority), outro valor = 422 priority_invalid; tudo conferido ANTES de gravar. Mudança audita no contest (priority de=… para=… via=regras) e na trilha central do treino (contest-priority); mesma prioridade = nada gravado. (show_editor:false com o módulo esqueletos ligado = 409 module_needs_editor, recusado ANTES de gravar qualquer campo do POST — 2026-09-30.) (show_code foi REMOVIDO em 2026-09-18 e allow_late em 2026-09-28 — era o adduser do bot do MOJ antigo, nada o lia: o GET não os devolve; o POST aceita e IGNORA as chaves — cliente antigo manda o formulário inteiro — e apaga as linhas SHOWCODE/ALLOWLATEUSER do conf. A criação também ignora allow_late.) GET traz também modules[] (ids ligados; muda-se em /contest/admin/modules) e statement_langs/statement_langs_mode (auto|list)/default_statement_lang (só leitura aqui; muda-se em /contest/admin/statement-langs). Tempos, login on/off, abertura, freeze (**freeze:0 = DESCONGELAR só a partir de freeze_release_at = contest_end_all + 60 s, prorrogações por sede incluídas — 409 freeze_locked com a hora na mensagem; vale p/ TODOS os caminhos que zeram o freeze: este, config basic.freeze, finish, promoção de rodada e rounds set na ativa (freeze_change_guard, comparação NUMÉRICA — "00" é zero); empurrar um freeze JÁ EM VIGOR para depois de agora também é descongelar (409); mover o freeze antes de ele entrar em vigor é livre; o GET traz freeze_release_at p/ a UI mostrar a hora — regra de 2026-09-14), locale (pt|en|es; outro = 422 locale_invalid), tz (fuso IANA da prova → CONTEST_TZ; vazio/null volta ao MOJ_TZ da instalação, 422 tz_invalid se não existir no zoneinfo; nome ANTIGO — America/Buenos_Aires, Asia/Calcutta, que o Chrome usa — é aceito e gravado com o nome atual pelo mapa do tzdata.zi (tz_canon, lib/common.sh; a imagem não tem os arquivos antigos) — governa TODA hora que o SERVIDOR escreve p/ gente sobre o contest: DM do mojinho, preflight, caderno, relatório; a web sempre mostrou no relógio do browser), toggles show_log/show_editor/show_tl/score_anon/allow_backup/allow_print/manual_verdict/secret, login_ua_substring, languages[] (whitelist do contest), judges[] (pool de juízes do contest: hostnames do registro, vazio = qualquer juiz online; vira CONTEST_JUDGES no conf — o job leva allowed_hosts e o escalonador é ESTRITO: pool offline segura a fila; o TL de /contest/problems passa a ser só do pool), score_full_users[] (logins que veem o placar completo além de .admin/.judge/.cjudge). Penalidade ICPC: penalty_minutes (int, default 20) e penalty_verdicts (array de códigos wa/tle/mle/rte/ce, default sem ce) — quais verdicts contam penalidade e o peso por tentativa; Judge Error/pendentes nunca contam; mudar freeze/penalidade dispara rebuild FORÇADO (score_kick_rebuild — imune à corrida de mtime com build em voo; ver /contest/admin/finish). O GET devolve também mode (read-only, modo do placar). show_log é o valor EFETIVO: em modo icpc com SHOWLOG ausente do conf o default é false (o report expõe os testes — anti-vazamento); no POST, show_log:true grava SHOWLOG=1 explícito (religar fica registrado) e false grava SHOWLOG=0. secret = SUPER SECRETO (fora das listagens públicas; placar/visual exigem login no contest; a UI exige digitar o id p/ desmarcar). manual_verdict (opt-in, default OFF) liga o veredicto manual: o daemon SEGURA o veredicto computado p/ revisão de juízes humanos (exceto o que a matriz auto-verdicts libera). review_judges (int 1..5, default 2 = ausente do conf; vira REVIEW_JUDGES) = QUANTOS juízes validam cada veredicto — N votos unânimes liberam; divergência vira conflito p/ o chief; 1 = revisão simples. Desligar manual_verdict VARRE a fila de revisão: o que ninguém contestou (sem voto e sem conflito) é liberado com o veredicto COMPUTADO — senão as sobras ficavam presas p/ sempre (o juiz comum não consegue mais votar e o competidor fica em Not Answered Yet); item com voto ou em CONFLITO não é atropelado e fica p/ o juiz-chefe. A resposta traz review_released/review_pending; auditado review-manual-off. balloons_during_freeze (bool, default false = retém) = entregar balão com o placar CONGELADO. Default protege o freeze: AC feito no congelamento não vira tarefa de entrega e não é entregue depois (ver /contest/staff/queue). LIGAR libera retroativamente o que ficou retido (apaga as lápides + o stamp; o próximo carregamento da fila materializa tudo — id determinístico, não duplica) e a resposta traz balloons_released; auditado balloon-freeze-release. O GET traz também balloons_frozen = quantos estão suprimidos agora. balloon_style (icon|fill, default icon = SCORE_BALLOON_STYLE ausente do conf; 422 balloon_style_invalid) = como a célula "resolveu" é pintada no placar/cerimônia/relatório (ver SCOREBOARD.md). guest_numbering (bool, GUEST_NUMBERING; issue #25) = convidados (coorte unranked) numerados na sequência própria — a 1ª linha do TXT da visão com convidados vira icpc s g; mudar dispara rebuild
/contest/admin/judges?contest=<c> GET admin ou juiz-chefe {judges:[{host,cpu,arch,langs,cage_root,last_seen,online}]} — os juízes do registro pull, o MESMO formato do /problems/judges (lib/judges-registry.sh). É a lista do seletor de pool de juízes em Regras: no subdomínio do contest o roteador barra /problems/* (403 contest_isolated).
/contest/admin/seed?contest=<c> POST admin do contest, e só com DEMO=1 no conf (senão 403 demo_required) povoa um contest de DEMONSTRAÇÃO com times e submissões SINTÉTICAS — existe para quem desenvolve o Animeitor (ou qualquer cliente de placar) ter um placar de verdade para trabalhar sem uma prova acontecendo. Body (tudo opcional): {teams:20, submissions:200, seed:1, freeze_minute, window_minutes, password:"demo1234", verdicts:{accepted,wrong,tle,rte,ce,pending}}. Cria os times que faltarem (time-01…N, com .team sigla/bandeira/sede) e escreve as submissões pelos mesmos escritores do veredicto real (user_history_append + metrics_recompute + score/build.sh) — o resultado é indistinguível para placar, estatística, webcast e balões. Determinístico pelo seed (mesmo seed ⇒ mesmo placar; a janela é arredondada a minuto cheio e window_minutes a fixa). freeze_minute grava o FREEZE_TIME (é o que faz placar.txt diferir de placar-full.txt). O probid gravado é o canônico (PROBS[i+4]) — qualquer outra grafia deixaria a célula em branco no placar em silêncio. Limites: teams 1..500, submissions 0..20000. É ADITIVO: chamar de novo soma ao que já existe (times que já existem não são recriados) — para começar do zero, apague o contest e crie outro. Resposta: {teams, teams_created, submissions, seed, freeze_time, window_minutes, password, board_lines, runs_after_freeze, hint, by_verdict{}} — runs_after_freeze:0 com freeze pedido vem com hint: o congelamento caiu na borda da janela semeada, o placar congelado sai igual ao completo e não há revelação para testar. Auditado (seed). ⚠ a marca DEMO=1 só é gravada na CRIAÇÃO do contest (demo:true no spec do /treino/contest-create/create) — não há toggle que a ligue depois
/contest/admin/problems?contest=<c> GET/POST admin GET → {problems:[{source,problem_id,name,letter,statement_key,languages,judges,titles?}], name_lang} — titles {pt,en,es} = os títulos do banco quando há MAIS DE UM (as opções de nome do painel e do moj-contest problems titles); name_lang = o idioma do nome padrão (o da sanfona: LOCALE se oferecido em STATEMENT_LANGS, senão o 1º). {action:add|remove|reorder|rename|apply_titles} (reescreve PROBS) — add sem name = o título do banco no idioma da prova (senão o PT); apply_titles {letters?} → {saved, changed:[{letter,name,to,lang}], problems}: troca pelo título no idioma da prova o nome que é um título do banco em OUTRO idioma, quando há título no idioma da prova (o botão do item prob_names da Central; nome personalizado nunca; nome dado pelo rename também não — ele fica em var/problem-names-chosen.json; nada a trocar = 200 com changed:[]; 03/10/2026) — rename {letter, name?, new_letter?} também troca o IDENTIFICADOR (^[A-Za-z0-9]{1,3}$; em uso — sem diferenciar caixa — = 422 letter_taken; a cor no balloons.json migra junto; muda só a 1ª entrada com a letra, o que desfaz a letra repetida de contest antigo) e reorder só re-letra pela posição quando as letras atuais são a sequência automática A,B,C,… — identificador customizado (W1…) sobrevive à reordenação; ao re-letrar, a cor do balão (balloons.json) e as clarifications (chaveadas pela letra) acompanham cada problema (auditado problems-reletter; 03/10/2026). Toda ação POST corre sob trava por contest (var/.problems.lock; 409 busy após 20 s) — o "+ adicionar todos" disparava os POSTs juntos e perdia problemas. statement {refresh:true} reindexa DESTACADO e só depois regrava o enunciado do contest (o atual fica até lá). GET traz phase (before|running|ended); order com letra repetida = 422 letter_dup, add {problem:{…, letter?}}: sem letter o problema recebe a 1ª letra LIVRE (A..Z, AA..ZZ — era a da POSIÇÃO e duplicava quando a sequência tinha lacuna), letter explícita já em uso (sem diferenciar caixa) = 422 letter_taken (PR #36, 2026-09-29), {action:langs,letter,languages[]} (whitelist por problema em problem-langs.json), {action:judges,letter,judges[]} (pool de juízes por problema em problem-judges.json; vazio = herda o pool do contest) ou {action:statement,letter, html_b64?|pdf_b64?|remove_html?|remove_pdf?|refresh?} (enunciado por problema em enunciados/<skey>.{html,pdf}; refresh re-indexa do banco). add de problema PRIVADO: só se o dono do contest (arquivo owner) for dono/colaborador do problema (mesmo guard da criação); senão 404 (não vaza a existência). Contest sem owner (legado): só público
/contest/admin/bank?contest=<c>&q=&limit=&collection= GET admin busca p/ adicionar problemas: banco público + os PRIVADOS a que o dono do contest tem acesso (dono/colaborador no índice — o mesmo sujeito do gate de add; a busca lista exatamente o que pode entrar). Privados primeiro. {problems:[{id,title,titles?,tags,collections,access:mine|shared|public,private,has_statement}],total,mine,shared} (titles {pt,en,es} só com mais de um título distinto — as opções de nome). Contest sem owner (legado) → só públicos. ?meta=1 → {tags:[{tag,count}],collections:[{collection,count}],private_included} (agregado do MESMO banco que o sorteio usa: público; com &include_private=1, também os privados do dono do contest — sem owner = só públicos, private_included:false; índice quebrado com o opt-in = 503)
/contest/admin/draw?contest=<c>&tags=&collections=&count=&match=&difficulty=&seed=&include_private= GET admin sorteio (mesmo contrato do draw do wizard: coleção/tag/dificuldade em AND, collections = array JSON url-encoded, reproduzível por seed; resposta com private_included e cada item com private/access/has_statement e titles?). Padrão: banco público. include_private=1 (opt-in, desligado por padrão) soma os PRIVADOS que o DONO do contest (arquivo owner — o mesmo sujeito do gate de add e da busca; nunca o login local do admin, que daria homonímia com o treino) pode usar (dono, colaborador ou membro da org — owners_visible_for), com privados vencendo o cache público (despublicado recém sai uma vez só); índice de owners quebrado com o opt-in = 503 index_unavailable. Contest sem owner (legado) = só públicos, private_included:false
/contest/statistics?contest=<c> GET admin/judge/mon totais, por-problema (first_minute relativo ao início + first_seconds p/ desempate + first_solver_name = nome do time de quem resolveu primeiro; estatística nunca mostra só o login, e o nome é resolvido no CACHE porque os dois consumidores — painel e relatório offline — não consultam contas), por-linguagem, veredictos, linha do tempo. Tempo = sub_epoch - CONTEST_START (não EPOCH). Só usuários normais (descarta .admin/.judge/.staff/.mon). Recortes prontos (2026-08-30): by_region:{<sede>: …} e by_country:{<flag>: …} — cada valor tem o MESMO shape do agregado global (totals/problems/languages/verdicts/timeline/dists, first_solver* recalculado DENTRO do recorte), computados na mesma passada do gerador; sede = .team.region e também cada NÓ da árvore de regions.json (país › região/supersede › sede — o nó agrega por REGEX de login, como o regionMatch do placar, com dedup quando nome == sede; regex inválida é descartada), então o seletor de Sede da estatística oferece a MESMA árvore do placar; país = o PREFIXO do .team.flag minúsculo (br-pr → br: time brasileiro declara bandeira de ESTADO e "estatísticas do Brasil" tem de juntá-los — o filtro "Bandeira" do placar casa pela mesma hierarquia); conta sem o dado fica fora do recorte correspondente. A UI (/contest/statistics/) expõe dois selects mutuamente exclusivos. População (2026-08-31, relato da LATAM): totals traz enrolled (INSCRITOS não-privilegiados, a mesma população do placar), users (quem submeteu) e absent (a diferença) — no global e em cada recorte; o bucket 0 do problems_solved_dist INCLUI os ausentes (a distribuição casa com o placar). Nó do regions.json com view:true (supersede/femininos — recorte que SOBREPÕE as sedes) sai com view:true na fatia e a UI avisa "não some com as sedes" (⚠ fatia é chaveada por NOME: dê nomes próprios aos recortes). Estatísticas 2.0 (2026-09-01): problems[] ganha avg_ac_min/tries_per_ac/dirt/difficulty (rótulo pelo accept_rate por time, mesmas faixas do treino — lib/difficulty.sh) (métrica do resolver ICPC: % de subs erradas entre quem resolveu)/ac_langs; cada recorte tem dirt; o GLOBAL ganha ac_events ([[login,prob,minuto,tentativas]…], 1º AC de cada time×problema, convidados inclusos — base ÚNICA das seções corrida/comparação/desempenho, que a UI filtra pelo recorte corrente), teams_idx (login→{n:nome,c:país,r:sede} de todo time com AC), penalty_minutes e unranked_regex (a regex das coortes convidadas, p/ o cliente aplicar o MESMO corte do ranking), top_teams (10, oficiais) e performance (média/mediana/quartis/p90 de resolvidos; média/mediana/quartis de penalidade ICPC; first_ac_median) — a UI recomputa desempenho/top 15 client-side por recorte e só mostra o quadro com ≥30 times com AC. Cache em var/statistics.cache.json (server/score/stats-gen.sh), invalidado por history/conf.
/contest/clarifications?contest=<c> GET Bearer role-aware (admin/judge/mon = todas; demais = próprias + públicas, sem answered_by). Quem perguntou (login + asker_name) só o juiz-chefe/admin recebe (2026-09-14, pedido do juiz-chefe); .judge/.mon continuam SEM .login (tratamento isonômico) e o relatório público segue anônimo. Privilegiado recebe answer_claim (reserva expirada já vem null). Envelope: {clarifications, can_answer, can_edit, is_chief, me} — can_edit = juiz-chefe OU admin (editam resposta dada, liberam reserva alheia com force); is_chief é compat e vale o mesmo
/contest/clarification-ask?contest=<c> POST Bearer {problem?,question}. Gate de janela (competitor_write_guard, a MESMA do /submit; 2026-09-15): time e .mon só durante a prova — antes 403 contest_not_started, depois 403 contest_ended (fim EFETIVO da sessão: sede prorrogada segue perguntando); .staff/.cstaff/.animeitor nunca (403 role_forbidden); admin/juiz/chefe sempre
/contest/clarification-claim?contest=<c> POST admin/judge/mon {id,action:claim|release,force?} — reserva p/ responder (dois juízes não pegam a mesma; TTL CLAR_TTL 5 min, expira na leitura). Ninguém reserva por cima de outro (409 clar_claimed, juiz-chefe incluso). release de reserva ALHEIA só com force:true e juiz-chefe/admin (senão 409 clar_claimed); a resposta traz forced_from e o audit clar-release … forced_from=<juiz>. Auditado (clar-claim/clar-release)
/contest/clarification-answer?contest=<c> POST admin/judge/mon {id,answer,public?} — sob flock + reserva; já respondida só o juiz-chefe/admin edita (409 already_answered); abertas exigem a reserva (409 clar_claimed). Auditado (edited=)
/contest/clarification-broadcast?contest=<c> POST admin/judge/mon aviso oficial {problem?,question?,answer} — answer é o TEXTO do aviso (obrigatório; 422 answer_missing); question é o ASSUNTO, opcional (a UI o mostra como título). Público, broadcast:true, autor oculto (login:""; UI mostra "Organização"). Auditado
/contest/admin/cohorts?contest=<c> GET/POST Bearer (GET admin ou .cjudge; POST só admin) coortes de placar (times oficiais × CONVIDADOS/extra-oficiais; motor em lib/cohorts.sh, formato em docs/SCOREBOARD.md). GET → {cohorts:[{id,name,regex,public,unranked,default,sees}], results_released, views, counts:{id:n}, by_regex_only:[login]}. POST {action}: add/set {id,name?,regex?,public?,unranked?,sees?,default?} (regex tem de COMPILAR; máx 8 coortes; a marca default é única e sempre existe) · rm {id} (recusa a default e coorte com time: 409 is_default/cohort_in_use) · assign {login,cohort} (grava .team.cohort; "" devolve o time à regra) · materialize (carimba o campo em quem hoje casa só por regex) · release {on} = liberar os resultados (todos passam a ver todos). Tudo auditado (cohorts-*) e toca var/.score-dirty. Coorte privada com sees escolhido SEMPRE se vê (a visão é sees + ela mesma; o add/set a acrescenta); coorte PÚBLICA unranked (extra-oficial sem coorte privada) entra no placar público pela coluna guest, sem consumir posição; views traz public (03/10/2026)
/contest/admin/statement-langs?contest=<c> GET/POST admin ou .cjudge idiomas do enunciado que a sanfona oferece (conf STATEMENT_LANGS). GET {mode:auto|list, langs[], default, all:[pt,en,es], locale, available:{<letra>:{<lang>:true}}} (available = idioma com arquivo no contest ou tradução no banco). POST {mode:"auto"} = automático (o default; apaga a var — todo idioma que cada problema tem entra na sanfona) ou {langs:["pt","en"]} = lista fixa (allowlist pt/en/es, 422 lang_invalid; vazia ou só pt = prova só em PT, gravado STATEMENT_LANGS=pt; 400 sem mode/langs). Os dois materializam do banco as traduções que faltam (enunciados/<skey>.<lang>.html), tocam var/.problems-dirty e auditam statement-langs (2026-09-15).
/contest/admin/rounds?contest=<c> GET/POST Bearer (GET admin ou .cjudge; POST só admin) · undo {confirm:<id do contest>} → {undone, restored, pending, users, submissions, kept} — DESFAZ a ÚLTIMA promoção (TCP 2026, 03/10/2026): devolve ao vivo o que a promoção moveu p/ rounds/<rodada>/, reaplica a rodada arquivada no conf e a que entrou volta a pending; só se a rodada no ar não teve NENHUMA atividade (409 undo_blocked com round_has_activity/jobs_in_flight/no_promotion; sem o id = 422 confirm_required); a sobra do arquivo vai p/ rounds/.desfeitas/. Bloqueador novo da promoção: official_over (a rodada no ar é a prova OFICIAL já encerrada — aviso; o force passa) rodadas: GET → {active, rounds[], next, promote_ready:{ok, blockers:[{code,detail,detail_en,detail_es}]}, undo:{from, to, possible, blockers[]}, titles:{<id>:{pt,en,es}}} (titles = os títulos por idioma dos problemas das rodadas que têm mais de um — problema de rodada sem name entra no ar com o título no idioma da prova) (a rodada ATIVA é espelhada do conf a cada leitura — editar em ⚙️ Configurações/📚 Problemas nunca diverge). POST {action}: add {slug,name?,kind?,start,end,freeze?} (rodada planejada; kind ∈ warmup|official|extra; freeze tem de cair na janela) · set {slug,new_slug?,…} (renomeia e edita; a ativa vai direto p/ o conf — pelo OBJETO editado, senão o espelho conf→json anularia a edição) · problems {slug,problems[]} (guarda de problema privado = a MESMA de Prova › Problemas e do wizard, problems_denied_for: público, ou o dono do contest é dono/colaborador/membro da org do problema — vale igual p/ rodada ativa e planejada; 403 problem_denied com a lista; era só dono-ou-público até 2026-09-14) · set aceita também colors = cores de balão DA RODADA (formato do balloons.json: {A:"RRGGBB",…,enableSonic:bool}; 422 colors_invalid; null remove — a rodada volta a herdar; {} não mexe): na rodada ATIVA grava o balloons.json na hora (= Evento › Balões, que o GET espelha em colors); na planejada fica no plano e entra no ar na promoção — sem colors, a promoção mantém as cores em vigor; chave = letra ^[A-Z0-9]{1,3}$; na ativa a resposta traz balloons_recolored/balloons_printed_old (como /contest/admin/config); a promoção restaura do arquivo só a CONFIG feita à mão (templates *.md, capa e info sheet enviados, logo) — caderno/TL/editorial enviados são da rodada e não voltam; o arquivo rounds/<slug>/balloons.json guarda as cores que a rodada usou · remove {slug} (só pending — arquivada é auditoria) · publish {slug,on} · promote {to?,force?}. Promover = arquivar a rodada ativa + zerar o store + aplicar a janela/PROBS da próxima; recusa com 409 not_ready + error.blockers (DENTRO do error, como o undo_blocked — é o que o cliente lê; round_running, jobs_in_flight, pending_verdicts, review_pending, judged_down, no_next_round, shared_users, round_no_problems = a rodada seguinte não tem problemas e a prova seguiria com os da rodada no ar — aviso, o force passa), freeze_locked = placar congelado antes de freeze_release_at — fim geral + 1 min; force:true ignora todos menos no_next_round/freeze_locked/problem_denied/letter_dup. Tudo auditado (round-add/set/problems/remove/publish/promote[-forced] Problema na rodada (action:problems): a guarda é problems_denied_for com o DONO do contest como sujeito e a recusa é 404 problem_denied sem listar ids (existência de privado alheio não vaza). action:set na rodada ATIVA passa por freeze_change_guard (freeze 0/"00" ou freeze em vigor empurrado p/ o futuro = descongelar ⇒ 409 freeze_locked). Bloqueador letter_dup (DURO): a rodada planejada tem letra de problema repetida (entrou pelo spec unificado ou por um rounds.json anterior à regra) — a recusa tem de ser ANTES da promoção, porque o rd_apply_obj grava a janela no conf antes do PROBS; na porta action:problems, letra repetida (sem diferenciar caixa) = 422 letter_dup. Bloqueador problem_denied (DURO, force não passa) na promoção: a rodada planejada tem problema privado que o dono não pode ver — última porta antes de cc_build_probs materializar o enunciado (a lista pode ter vindo do spec unificado/duplicate). (2026-09-15)