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

MOJ — API v1 (referência)

Base: /api/v1. Roteador único: server/api/v1/router.shhandlers/<rota>.sh. 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 (≠ 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,has_photo} (.cjudge = juiz-chefe → is_judge:true,is_chief:true; .cstaff = chefe de sede → is_cstaff:true, sem herdar is_staff). 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=n403 login_disabled; antes de LOGIN_START_TIME403 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_closedaquecimento 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}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 o contest tem INSCRIÇÃO ligada — 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). 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; flock)

Treino

Rota Auth I/O
/treino/problems array [{id,title,tags,collections,solved_count,attempted_count,public_at?}] (collections = .moj-meta.json do pacote, um problema pode estar em várias; public_at = epoch da 1ª publicação, 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} (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). O owner carimbado nos problemas mantém o login histórico (como autor de commit) — o ACESSO vem da org, que segue o rename. 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, 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?} · 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,kind,params,enabled}]} — 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) + 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 — 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 (X-Forwarded-For/REMOTE_ADDR) 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}]}
/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-], 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). &details=1pending_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,machines:[{host,online,busy,status,langs,cage_root,cache,tl,current,current_jobs,queued_calibrate,slots,partition,topology,config,report}]}current_jobs = TODOS os jobs em execução (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?} (admin) config fina POR JUIZ (multi-slot): particionamento da máquina em slots com pinning, cpus reservadas e desabilitar (drena). Vive em contests/treino/var/judges-config.json; o heartbeat entrega ao agente quando muda (cfg_hash) 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 é delemembro 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)
/problems/validation?id=<id> GET último relatório de validação {checks:[{name,ok,detail}],html_built,render_warnings,ok}
/problems/status 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,errors},calibrating_ids,attention_ids,problems:[{id,title,owner,author,public,validated,calibrated,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}]}. 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,good_langs,missing_langs,tl_override,time_limits,time_limits_calibrated,hosts:[{host,tl,missing,at,log,reports,sols}]}. ⚠ hosts[].tl e sols[].tests[].tl são a MEDIÇÃO da calibração, nunca o override — o calibreitor roda com MOJ_CALIBRATING=1 justamente para medir de verdade; time_limits (efetivo) existe para o cartão poder dizer o julgamento usa outro número. missing_langs = linguagens good sem TL em nenhum host (solução good falhou em TODAS as máquinas); hosts[].missing = faltantes naquele juiz. sols = a calibração POR EXTENSO daquele juiz, estruturada p/ ferramentas externas: [{file,lang,category:good|pass|slow|wrong,verdict,tests:[{name,code,time,tl}]}] — o MESMO formato do vetor tests de uma submissão normal (nome do teste, código curto AC/WA/TLE/…, tempo em s, TL usado); [] = juiz ainda não reportou o vetor (mojtools/agente antigos — o log texto continua). tl_override = o TLOVERRIDE do conf do pacote (ver PACOTE.md), {} sem override. Sols .py2/.py3 legadas contam como py
/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,title,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} SÓ MEMBRO da org (require_problem_edit); não-autorizado recebe 404 (sem read-only, sem atalho de .admin). 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?} 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). 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} 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
/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}. 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). languages = ids permitidos de submissão ([]/ausente = todas)
/problems/edit POST {id, ...campos} edita (só campos presentes); commit+push autorado. 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,files:[{path,content_b64,exec} | {path,symlink}]}]}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).

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ícita409 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). 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"} (não bloqueia). 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).cppl.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 .mondurante 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).
/submission/source?contest=<c>&id=<subid> GET Bearer código-fonte (texto)
/submission/log?contest=<c>&id=<subid> GET Bearer log do julgamento (report.html; expõe input+diff de TODOS os testes). 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?} }. 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_kindtests|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} (languages = whitelist do conf LANGUAGES=; [] = todas; locale = pt/en explícito impõe o idioma da interface do contest, "" = 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 opcionais}
/contest/navbuttons?contest=<c> Bearer botões por papel (.admin/.judge/.staff/.cstaff — o .cstaff ganha 🏷️ Etiquetas e, quando o contest terminou p/ todas as sedes, 🏆 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,time_limits,languages,author?}]} (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.jsonCONTEST_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"} (.stafflocked:"staff"), e o front mostra a tela de contagem regressiva. Resposta em CACHE (`var/problems-cache.<author
/contest/statement?contest=<c>&problem=<letra|problem_id>&format=html|pdf Bearer UM enunciado, cru (text/html ou application/pdf; format default html). 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; .staff403 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 é organização (admin/chief/judge/staff/cstaff/mon), 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/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), 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/teamsou pelo regex no login)
/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). 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).

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.jpgfulano), 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.mp3fulano), 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_padraopadrao/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/webcast?contest=<c> | 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}}
/contest/admin/config?contest=<c> POST {colors?,regions?,teams_meta?,basic?} → grava balloons.json/regions.json/teams-meta.json + vars basic no conf (vazio = reseta)
/contest/admin/users?contest=<c> GET {users:[{login,fullname,email,admin,disabled,disqualified}],shared} (sem senha)
/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
/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. → {created:[{login,password,fullname,email}],updated:[…],skipped:[{login,reason:exists|privileged|invalid|duplicate}],counts}. Auditado users-bulk
/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/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/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). 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 (configuráveis). GET → {verdicts:[labels], options:[{label,verdict}]}; POST {options:[{label,verdict}]} (verdict canônico, sem :; o "YES" deve começar com Accepted p/ o placar). Default = as 6 (1-YES…6-Contact staff). Auditado (final-verdicts-set)
/contest/auto-verdicts?contest=<c> GET/POST GET=judge; POST=admin/chief matriz de veredicto automático { "<cid>": { "<lang|*>": ["<verdict>"] } } (problema × linguagem × veredicto). GET → {matrix,problems,verdicts}; POST {matrix} (cids validados; lang minúsculo ou *). 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} — o juiz-chefe resolve o conflito; libera ao aluno. 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) — n alimenta o alerta global de conflito (banner + bip) que segue o chief/admin em qualquer página (shared/chief-alert.js, disparado via auth.status)
/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
/contest/set-verdict POST admin ou juiz-chefe {contest,problem_id,verdict,username} — override direto (modo legado/auto-resposta); agora consumido pelo daemon (setverdict) e finalizado pelo escritor único
/contest/rejudge POST admin/chief {ids:[…]} — marca cada submissão como pendente e RE-JULGA (o daemon reconstrói a fonte arquivada + metadados do history)
/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 bot avalia incidentes (juiz offline+fila, fila grande, daemon caído, bot fora do arbot_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:[…]}; 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).

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/. 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 — 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). 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,reason,solved_count,threshold,in_allow,in_deny,allowed_modes,login,name} | | /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,tags,access:mine\|shared\|public,private}],mine,shared,total}. 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 | GET | Bearer+criador | tags do banco com contagem {tags:[{tag,count}],total} | | /treino/contest-create/collections | 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) | | /treino/contest-create/draw?tags=&collections=&count=&match=any\|all&difficulty=any\|easy\|medium\|hard\|known&seed= | GET | Bearer+criador | sorteia problemas por tag, coleção e dificuldade (filtros em AND), reproduzível por seed {problems[],candidates,drawn,seed,collections}. 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?,showcode?,allow_empty?, admin:{login?,password?,fullname?}, (users_from? \| users:[{login,password?,fullname?,email?, univ_short?,univ_full?,country?,region?}]), problems:[…], colors?:{A:"RRGGBB",…,enableSonic?}, regions?:[…], teams_meta?:[{regex,country,school?,school_full?}], locale?,login_start?,login_enabled?,freeze?, show_log?,show_editor?,show_tl?,allow_backup?,allow_print?,score_anon?,manual_verdict?,allow_late?,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; super só admin — como mode:outro). 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 é reutilizadoadmin_reused:true, admin_password:null | | /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. 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). 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?} → cria contest novo copiando conf+problemas+visual do from (usuários/submissões nunca; enunciado custom copiado por arquivo). 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) | | /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 | lê/define {threshold,allow[],deny[]} | | /treino/admin/contests | GET | admin | contests criados pela interface | | /treino/admin/contest-remove | POST | admin | {contest} → move p/ lixeira (só os criados pela interface) |

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|textls/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/. 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 + alerta de UA/IP diferentes
/contest/admin/access-log?contest=<c>&day= GET admin log de acessos (epoch/login/ip/UA) + alertas
/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 --slurpfilenunca --argjson (o array do admin-audit sozinho passa dos 128 KiB de MAX_ARG_STRLEN)
/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)
/contest/admin/settings?contest=<c> GET/POST admin tempos, login on/off, abertura, freeze, locale, 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 — 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_code/show_log/show_editor/show_tl/allow_late/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)
/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 inclui languages e judges por problema; {action:add|remove|reorder|rename} (reescreve PROBS) — rename {letter, name?, new_letter?} também troca o IDENTIFICADOR (^[A-Za-z0-9]{1,3}$; em uso = 422 letter_taken; a cor no balloons.json migra junto) 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, {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,tags,collections,access:mine|shared|public,private,has_statement}],total,mine,shared}. Contest sem owner (legado) → só públicos. ?meta=1{tags:[{tag,count}],collections:[{collection,count}]} (agregado do banco público — sorteio é público)
/contest/admin/draw?contest=<c>&tags=&collections=&count=&match=&difficulty=&seed= GET admin sorteio no banco público (mesmo contrato do draw do wizard: coleção/tag/dificuldade em AND, collections = array JSON url-encoded, reproduzível por seed)
/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-prbr: 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 (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). O asker (.login) NUNCA é exposto — nem aos juízes (tratamento isonômico; recuperável só pelo admin via auditoria clarification-ask). Privilegiado recebe answer_claim (reserva) e is_chief
/contest/clarification-ask?contest=<c> POST Bearer {problem?,question}
/contest/clarification-claim?contest=<c> POST admin/judge/mon {id,action:claim|release}reserva p/ responder (dois juízes não pegam a mesma; TTL 5 min, expira na leitura). 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} — Q+A público já respondido, autor oculto (login:"", broadcast:true; 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
/contest/admin/rounds?contest=<c> GET/POST Bearer (GET admin ou .cjudge; POST só admin) rodadas: GET → {active, rounds[], next, promote_ready:{ok, blockers:[{code,detail}]}} (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; kindwarmup|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[]} (mesma guarda de problema privado do wizard) · 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 + blockers (round_running, jobs_in_flight, pending_verdicts, review_pending, judged_down, no_next_round, shared_users), force:true ignora todos menos no_next_round/shared_users. Tudo auditado (round-add/set/problems/remove/publish/promote[-forced])
/contest/admin/round-archive?contest=<c>&round=<slug> GET Bearer (admin) tar.gz do arquivo CRU da rodada (rounds/<slug>/: history, código-fonte, mojlog, results, review, clarifications, backups, logs copiados, conf.snapshot, meta.json). Auditado (round-archive-download)
/contest/admin/ua-gate?contest=<c>[&login=<l>] GET/POST Bearer (GET admin ou .cjudge; POST só admin, action:"check" também p/ chefe) gate de navegador POR SEDE (lib/ua-gate.sh, contests/<c>/ua-gate.json): a imagem de prova de cada sede manda um UA com um pedaço do próprio login do time (teambrspso001brspso). GET → {gate:{mode,from_login,by_region,by_regex,exempt,fallback}, legacy, regions, check} — com ?login= faz o dry-run (o esperado daquele time, sem ele logar). POST {action:"set", …} valida que todo regex COMPILA (422 regex_invalid), ≤200 chars, ≤100 sedes, ≤50 regras, ≤200 isentos; {action:"check", login} = o dry-run. Ordem de resolução: exempt › conta de papel › by_regexby_region (sede do time) › from_login (captura \1) › fallback/LOGIN_UA_SUBSTRING legado. mode:"off" desliga sem apagar a config. Auditado (ua-gate-set)
/contest/admin/machines?contest=<c>[&round=<slug>] GET Bearer (admin ou .cjudge) mapa de máquinas da rodada — time × IP × UA, agregado do var/access.log recortado pela janela da rodada (nada novo é capturado): {window, by_login:[{login,name,region,ips,uas,pairs:[{ip,ua,n,first,last}],multi_ip,changed}], by_ip:[{ip,logins,shared}], uas[], ua_suggestion, totals}. changed = IP/UA diferente do machines.json da última rodada arquivada (o time trocou de máquina depois do aquecimento); ua_suggestion = maior substring comum dos UAs vistos; ua_expected/ua_match por time = o que a imagem da sede dele deveria mandar × o que veio (é como se conserta a sala no aquecimento), e totals.ua_mismatch conta quem está fora. Só leitura: preencher sede = POST /contest/admin/teams {set:…}, armar gate = POST /contest/admin/settings {login_ua_substring}. Auditado (machines-view)
/contest/admin/docs?contest=<c> GET/POST Bearer (admin OU .cjudgeis_admin_or_chief) gestão dos documentos da prova (aba 📄 Documentos; motor em lib/contest-docs.sh, PDF via soffice, único engine da imagem). GET → {docs:[{type,lang,…,published,uploaded,uploaded_bytes,uploaded_at}], config:{…}, langs:["pt","en","es"], templates:{info_sheet:{<lang>},cover:{<lang>}, …chaves planas antigas}, cover_uploaded:{<lang>:bool}, problems:[{letter,name,has_pdf,has_html}]}. POST {action}: config {caderno_version?,cover_note?,errata?,info_sheet_<lang>?,cover_<lang>?} (texto vazio = volta ao default embarcado de server/etc/); cover {lang,pdf_b64} \ Tipo novo editorial (a solução de cada problema, do docs/solucao.md do PACOTE via pkg_path — o campo que nunca vai ao aluno; nota introdutória editorial_note no config): publish de editorial exige contest_over_for_all (403 contest_running); publish com news:true de contest/times antes do início é recusado (409 news_before_start — a notícia anexa o PDF e vaza fora do gate de fase).
/contest/admin/news?contest=<c> POST admin/judge/mon (edit só admin/chief) {action:add|remove|edit,…} notícias do contest; add aceita anexo {filename,file_b64} (em news-files/<id>/); edit {id,title,text} (notícia já enviada). Auditado (news-add/remove/edit)
/contest/admin/backups?contest=<c>[&user=&q=] GET admin lista TODOS os backups (filtra por login/nome) {backups:[{login,id,name,size,time}],users:[{login,count,bytes}]}
/contest/nutella?contest=<c> GET/POST GET: admin/chefe/.cstaff/.staff · POST config/collect/push-roster: admin · POST command: admin ou c/staff escopado Integração NUTELLABOOT (máquinas mlinux das sedes) — ver NUTELLABOOT.md. GET → {configured, url, status, can_admin, scoped, data}; data = o panorama coletado (var/nutella.cache.json, version:2: contest{start,end}, `link{mode:ua
/contest/admin/backup-zip?contest=<c>&login=<l> GET admin baixa um zip com todos os backups do usuário (nomes originais, prefixados por data)
/contest/admin/report?contest=<c> GET admin baixa o relatório estático da prova: tar.gz com um site navegável offline (file:// ou qualquer web server): index.html (placar ABERTO, sem freeze + info + links dos enunciados), score-frozen.html (só com freeze), runs.html (todas as submissões: min/hora/time/login/prob/ling/veredicto canônico), statements/<letra>.{html,pdf}, clarifications.html (todas, asker e respondente anônimos), statistics.html, staff-tasks.html (impressões+balões, só metadados) e infra.html (juízes, espera avg/p50/p95/máx da prova inteira, por problema/juiz, timeline). Gerado na hora por server/score/report-gen.sh (roda build.sh+stats-gen.sh antes; balões reconciliados). NUNCA sai: código-fonte, mojlog, tests[]/tl_used dos results, senhas/e-mails, asker de clarification, .src de impressão. Concorrência → 429 busy. Auditado (report-download). Visual e conteúdo (2026-08): usa a identidade do MOJ (o web/shared/ui.css é inlinado, topbar com logo em data: URI), bandeiras viram o mesmo SVG do placar ao vivo embutido em data: URI (o emoji antigo não renderizava e código de ESTADO — br-rj — saía como texto), placar com coluna Penal. + desempate e linha de convidado iguais aos do placar oficial, lista de problemas com Autor (arquivo author do pacote), statistics.html com as mesmas seções do painel (inlina web/lib/stats-view.js + charts.js + shared/dom.js, com <noscript> mantendo as tabelas) e aba 📄 Documentos com os documentos publicados copiados p/ documentos/. Bilíngue: TODAS as páginas seguem o LOCALE do contest (tabela rep_t no gerador, no molde do _doc_t; blocos awk/jq recebem os rótulos por -v/--arg), inclusive o formato de data e o <html lang>
/contest/admin/finish?contest=<c> GET/POST GET: admin ou juiz-chefe · POST: admin ENCERRAR O EVENTO — o espelho pós-prova do preflight. GET → checklist {checks:[{id,level,label,detail}], summary:{ok,warn,fail}, can_finish, can_act, pending_docs:[{type,lang}]} (ids: fim, freeze, docs = o que o botão resolve; show_log, show_code, cohorts, report = informativos, só com atalho p/ o painel que resolve; can_act = true só p/ o admin — o juiz-chefe LÊ o checklist mas o POST é do admin, e o front usa o campo p/ esconder o botão). POST {action:"finish"}abre o placar (FREEZE_TIME=0 + rebuild forçado via score_kick_rebuild: remove var/.metrics-stamp, toca .score-dirty e dispara um build destacado que ESPERA o lock — um build em voo que terminasse depois da escrita do conf carimbava tudo "fresco" com os metrics pré-mudança e o placar ficava congelado p/ sempre; corrida de mtime, Maratona 29/08) e publica todo documento já gerado que ainda não estava publicado (doc_publish de lib/contest-docs.sh — mesmo caminho do painel de documentos, incl. o resources.json); devolve {finished:true, done:[{item,detail}], skipped:[{item,reason}]}. Idempotente. Só depois do fim para todas as sedes (409 contest_running); concorrência → 429 busy. Auditado (finish-event)
/contest/admin/staff-filters?contest=<c> GET/POST admin escopo dos usuários .staff e .cstaff (sedes distribuídas): cada entrada é region:<nome> (igualdade com a sede .team.region do aluno — sem regex) ou uma regex no login (clássico). No .staff governa a fila/ações; no .cstaff governa a fila (leitura), as etiquetas e a cerimônia da sede — configure-o sempre. GET → {staff:[{login,fullname,disabled}], filters:{login:[entradas]}, regions:[{name,regex}]} (regions p/ semear — a UI insere region:<nome>). POST {filters:{login:[entradas]}} (chaves = .staff/.cstaff existentes; vazio = vê tudo) → grava print-requests/staff-filters.json. Auditado (staff-filters)
/contest/admin/teams?contest=<c> POST admin gerência de times por-usuário (aba 👥 Times). {set:{<login>:{fullname?,univ_short?,univ_full?,country?,region?}}}fullname (o nome do time, campo único) mescla em .fullname (vazio = ignorado); os demais mesclam no .team ("" apaga o campo; ausente não toca); login inexistente → skipped; {action:"materialize"} = match de 1 clique: aplica teams-meta (regex→país/escola) e regions (regex→nome de sede) aos campos vazios de cada usuário, gravando por-usuário (nunca sobrescreve preenchido) → {materialized,filled}. Contest com USERS_FROM409 shared_users. Auditado (teams-set/teams-materialize)
/contest/admin/team-assets?contest=<c> POST admin upload de foto (photo.png, lado máx 1000) e brasão (logo.png, máx 128) do time: {kind:photo|logo, filename:"<login>.<ext>", file_b64} — UM arquivo por POST (a UI envia lotes em sequência); o basename sem extensão casa o login case-insensitive (não casou → 404). {action:"delete",kind,login} remove. Máx 8MB; USERS_FROM → 409; converte p/ PNG sem metadados. Auditado (team-asset)
/contest/admin/preflight?contest=<c> GET admin/chefe checklist pré-prova verde/amarelo/vermelho → {checks:[{id,level:ok|warn|fail,label,detail}],summary}. Verifica: janela, SHOWLOG efetivo (fail em icpc se visível — anti-vazamento), show_code, freeze na janela, juízes online (registry), pool de juízes (pool: fail se NENHUM host do pool do contest está online — modelo estrito, fila presa; warn p/ host offline/não registrado, inclusive dos overrides por problema; pool_problems: fail se o pool efetivo de algum problema está todo offline), toolchain das linguagens permitidas (só nos juízes do pool, quando definido), TL calibrado de cada problema do conf no pool efetivo (+ cache nos juízes online do pool), staff de impressão, contas de competidores, spool travado (daemon), modo/veredicto manual/prorrogações (informativos, e a prorrogação avisa quando o fim prorrogado passa do freeze), escopo do staff (staff_filters: warn quando .staff/.cstaff sem filtro veem a fila e as ETIQUETAS COM SENHA de todos), balões (balloons: warn com >15 problemas e sem balloons.json — da letra P em diante o balão sai cinza), coortes (cohorts: quantas privadas; warn quando os resultados já foram liberados), gate de navegador (ua_gate: fail se armado e a regra não casa NINGUÉM, warn com times sem regra — isento declarado não conta), rodada seguinte (next_round: o slug pendente + os bloqueadores de rd_promote_blockers) e documentos (docs: gerados/publicados). O contador de competidores exclui TODOS os sufixos de papel (inclusive .cstaff). É a lista da 🏁 Central do painel de admin
/contest/admin/registrations?contest=<c> GET/POST Bearer (GET admin ou .cjudge; POST só admin) inscrições do contest (roster + janela; motor em lib/registration.sh). GET → {enabled, remind, tz, window:{…,official_start,official_round}, round_kind, gate_active, team_max, teams_allowed, teams:[{login,name,captain,members,invited,invited_meta:[{login,at,dm_at,warned_at,tg}],cohort}], individuals:[{login,cohort,at,univ,flag,ai}], totals:{…,invites,invites_no_tg}} (invited_meta = desde quando o convite espera, se a pessoa tem Telegram vinculado (tg) e quando foi avisada). POST {action}: enable (cria o roster vazio + semeia as coortes) · disable (roster vira .off; ninguém é desmaterializado) · window {open?,close?,late_minutes?,team_max?,teams?,remind?} → grava REG_OPEN/REG_CLOSE/REG_LATE_MINUTES/REG_TEAM_MAX/REG_TEAMS/REG_REMIND no conf (campo ausente não mexe; null APAGA a chave — é assim que close:null faz a janela voltar a herdar o início da prova. Epochs são sempre UTC: quem formata p/ o organizador é o browser) · add/rm {login} · team-add {name,members[]} (o 1º membro é o capitão) · team-rm {team} · team-meta {team,univ?,ai?,flag?} (a organização ajusta a apresentação — o painel Pessoas › Times é 409 em contest com USERS_FROM) · individual-meta {login,univ?,ai?,flag?} (idem p/ um inscrito individual) · materialize (reescreve os overlays do store a partir do roster) · invite-remind {team,login} / invite-remind-all (o mojinho cutuca convite pendente por DM, com o link de aceitar/recusar; devolve remind_result:{sent,skipped[]} junto do estado — 404 no_invite, 409 no_telegram no singular, 409 registration_closed com a janela fechada). Auditado (reg-*)
/contest/admin/time-overrides?contest=<c> GET/POST admin prorrogação de vigência por sede/grupo (ex.: queda de energia numa sede ⇒ só aqueles times ganham minutos). Regras [{regex,end,reason?}] testadas contra o login — a 1ª que casa define o fim EFETIVO daquele grupo (só estende o CONTEST_END; nunca encurta; penalidade segue contada do início global). Aplicado na API por contest_end_effective (lib/contest-gate.sh): vale no /submit (403 contest_ended usa o fim efetivo) e no countdown (/contest/basic autenticado). GET → {rules,contest_end,regions} (regions p/ semear); POST {rules:[…]} substitui a lista (valida regex/end; máx 50) → grava contests/<c>/time-overrides.json. Auditado (time-overrides). CLI: moj-contest extend <+min|epoch> --group <regex>
/contest/admin/jplag-run?contest=<c> POST admin dispara o jplag (background)
/contest/admin/jplag-results?contest=<c> GET admin {status, results:[{problem,lang,pairs:[{a,b,similarity, a_login,a_name,a_univ, b_login,b_name,b_univ}]}]}a/b = nome sanitizado do arquivo dado ao jplag; a_login = login literal, a_name = nome do time (.team.name // .fullname), a_univ = sigla (campos aditivos; run antigo não os tem)
/contest/admin/jplag-match?contest=<c>&run=&i= GET admin HTML lado-a-lado da comparação
/contest/userinfo?contest=<c> GET Bearer + show_editor/show_log/show_code/is_mon/is_chief
/contest/admin/logout-user?contest=<c> POST admin {login} → encerra sessões do usuário
/contest/admin/user-disable?contest=<c> POST admin {login} → bloqueia (senha !…) + desloga (reabilita via user-add) — NÃO tira do placar (p/ isso: user-disqualify ou user-remove). Conta privilegiada (.admin/.judge/.cjudge/.staff/.cstaff/.mon) → 403
/contest/admin/users-set-password?contest=<c> POST admin {password,include_disabled?} → senha única p/ todos os não-privilegiados (prova; pula .admin/.judge/.cjudge/.staff/.cstaff/.mon). Recusa contest=treino (400 treino_forbidden — resetaria a plataforma inteira)
/contest/admin/logout-mismatch?contest=<c> POST admin desloga sessões cujo UA ≠ LOGIN_UA_SUBSTRING (preserva contas privilegiadas, incl. .cjudge)

Tela única /contest/admin/ (hub com sub-abas: Configurações, Problemas, Aparência, Usuários, Log & sessões); /contest/{admin_tasks,log}/ redirecionam para ela. Placar: score_anon no conf → modo anônimo (agregado/quartis, sem nomes); home abre contests pelo subdomínio. Auditado em contests/<c>/var/admin-audit.log: settings, problems-*, clarification-answer, clar-claim, clar-release, clarification-broadcast, news-*, jplag-run, logout-user, user-disable, users-set-password, logout-mismatch.