- Python 98.6%
- Shell 1%
- HTML 0.3%
A pipeline anterior morava no repositório que contém `backend/` e `frontend/`, e estava morta por dois motivos ao mesmo tempo: aquele repositório **não tem remote nenhum**, então nenhum forge o vê e nada é acionado; e desde `d6da838d` ele não versiona nenhum arquivo destas duas pastas, então o `cd backend` do primeiro passo falharia num checkout limpo. Efeito prático: nada do que foi construído nas últimas semanas — catraca de lint, gate de schema, gate de contrato, gate de autenticação — jamais rodou fora da máquina de quem escreveu. Seis passos: `lint` (catraca contra `lint-baseline.json`), `auth` (rota anônima sem decisão registrada), `test`, `schema` (`alembic check` mais round-trip completo de downgrade), `contract` (chamada do frontend para rota inexistente) e `audit` (`pip-audit`, só na main). Cada um traz escrito o defeito que o originou; gate sem motivo é o primeiro a ser desligado quando incomoda. `contract` é o único que precisa dos dois repositórios, então clona o frontend — preferindo a branch de mesmo nome, com a padrão como reserva: uma mudança que atravessa os dois lados vive em duas branches homônimas, e clonar sempre a padrão compararia backend novo com frontend antigo. Precisa da chave de deploy em `CI_SSH_KEY`; sem ela o passo falha, que é o certo para um gate cujo insumo não chegou. Verificado o que dava para verificar daqui: a auditoria de contrato roda contra um clone limpo do frontend, não só contra a árvore de trabalho. O clone por SSH não — este ambiente não tem acesso ao forge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| app | ||
| migrations | ||
| scripts | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| .woodpecker.yml | ||
| alembic.ini | ||
| app.json | ||
| Dockerfile | ||
| LICENSE | ||
| lint-baseline.json | ||
| main.py | ||
| Procfile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
Open LMS Backend
Backend FastAPI do PlayfulLMS (clone open source do MasterStudy).
Development
# Install dependencies
uv sync
# Apply migrations (needs postgres up: docker compose up -d db na raiz)
uv run alembic upgrade head
# Run development server (mock auth, sem Keyrunes real)
env KEYRUNES_MOCK=true uv run uvicorn main:app --reload --port 8000
# Dados de desenvolvimento (admin, instrutor, 2 alunos, 2 cursos, cupom, pedido pago)
uv run python scripts/seed_dev.py # idempotente
uv run python scripts/seed_dev.py --reset # remove o que o seed criou
# Run tests
uv run pytest
Docs interativas: http://localhost:8000/docs
Variáveis de ambiente
Lidas via pydantic-settings (app/infra/config.py), de um .env no diretório
do backend ou do ambiente. Os defaults servem para dev local.
| Variável | Default | Notas |
|---|---|---|
DATABASE_URL |
postgresql+asyncpg://postgres:postgres@localhost:5434/openlms |
Porta 5434 = docker compose up db da raiz |
SECRET_KEY |
change-me-in-production |
Trocar em produção |
ENVIRONMENT / DEBUG |
development / false |
|
CORS_ORIGINS |
http://localhost:3000,http://localhost:5173 |
Lista separada por vírgula |
FRONTEND_URL |
http://localhost:5173 |
Usado em redirects/e-mails |
KEYRUNES_API_URL |
http://localhost:8080 |
Em produção: https://sso.singularjourney.org |
KEYRUNES_API_KEY |
(vazio) | Bearer token enviado ao Keyrunes |
KEYRUNES_MOCK |
false |
true = auth fake, sem Keyrunes (ver abaixo) |
KEYRUNES_NAMESPACE |
public |
Namespace/tenant enviado no login e registro |
KEYRUNES_JWT_SECRET |
(vazio) | Obrigatório em produção. Mesmo valor do JWT_SECRET do Keyrunes; vazio = assinatura não verificada |
KEYRUNES_JWT_ISSUER |
keyrunes |
Claim iss exigido no token |
KEYRUNES_JWKS_URL |
(vazio) | Não usado — o Keyrunes é HS256 simétrico, não expõe JWKS |
STORAGE_TYPE |
local |
local ou bunny |
BUNNY_STORAGE_API_KEY / BUNNY_STORAGE_ZONE / BUNNY_CDN_URL |
(vazio) | Só com STORAGE_TYPE=bunny |
LOCAL_STORAGE_PATH |
./uploads |
Só com STORAGE_TYPE=local |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / STRIPE_PUBLIC_KEY |
(vazio) | Pagamentos |
Autenticação e autorização (Keyrunes)
O backend não tem auth própria: login, registro e senha são delegados ao
Keyrunes. A tabela local users guarda apenas perfil e papel, ligada pelo campo
keyrunes_id, e é sincronizada (upsert) a cada login/registro por
UserRepository.upsert_from_keyrunes.
De grupo do Keyrunes a papel do LMS
O Keyrunes emite o claim groups (mais namespace e organization_id) e
não emite role. O mapeamento está em app/infra/auth/roles.py:
| Grupo no Keyrunes | UserRole |
|---|---|
superadmin, admin |
ADMIN |
instructor, instructors, teacher, teachers |
TEACHER |
users e qualquer outro |
STUDENT |
Quem está em vários grupos recebe o papel mais alto. Promover alguém a instrutor é operação do Keyrunes:
cargo run --bin cli -- assign-user-to-group \
--username prof --group-name instructor --namespace public
Namespace
Namespace no Keyrunes é o identificador de organização/tenant (coluna
organizations.namespace, única, ≤63 chars). Toda chamada de login/registro e
toda operação de CLI aceita --namespace.
O namespace do PlayfulLMS é public — o default de fábrica do Keyrunes
(DEFAULT_NAMESPACE = "public" em src/constants.rs), atribuído à
"Default Organization" (organization_id = 1) pela migration
20260101120000_add_schema_fields.sql. Não criamos organização dedicada, então
não há namespace customizado a configurar: onde a CLI pedir --namespace, use
public ou simplesmente omita (é o valor default).
Grupos padrão dentro do namespace: superadmin, admin, users.
Criando o primeiro usuário admin
O primeiro admin é criado no Keyrunes, pela CLI dele — não existe endpoint de
bootstrap no PlayfulLMS. No diretório do Keyrunes (keyrunes/), com
DATABASE_URL apontando para o banco do Keyrunes:
# migrations do Keyrunes (cria org default + grupos superadmin/admin/users)
cargo run --bin cli -- migrate
# primeiro superadmin — vai para o namespace "public", organization_id 1,
# e é adicionado ao grupo "superadmin"
cargo run --bin cli -- create-superadmin \
--email admin@example.com \
--username admin \
--password 'senha-forte'
Atalho equivalente via Makefile do Keyrunes:
make cli-superadmin ADMIN_EMAIL=admin@example.com ADMIN_USERNAME=admin ADMIN_PASSWORD='senha-forte'
Usuários seguintes podem ir direto para um grupo:
cargo run --bin cli -- register \
--email prof@example.com --username prof --password 'senha' \
--namespace public --group admin
Comandos úteis: list-users --namespace public, list-groups --namespace public,
assign-user-to-group --username X --group-name admin --namespace public.
Modo mock (KEYRUNES_MOCK=true)
Para desenvolver sem uma instância do Keyrunes. O papel é inferido da parte local do e-mail, então dá para exercitar qualquer rota sem override de dependência:
| E-mail no login | Grupo | Papel |
|---|---|---|
admin@example.com |
admin |
ADMIN |
prof@example.com, teacher@…, instructor@… |
instructor |
TEACHER |
| qualquer outro | users |
STUDENT |
O token mock é mock_jwt_v2.<base64url(json)> e carrega o claim set completo
(sub, email, username, groups, namespace, organization_id). Tokens no
formato antigo mock_jwt_<id> continuam sendo aceitos e caem em STUDENT.
Verificação de assinatura do JWT
O Keyrunes assina com HS256 e segredo compartilhado (JWT_SECRET do lado
dele) — não existe JWKS para buscar chave pública. Configure
KEYRUNES_JWT_SECRET com o mesmo valor e o backend passa a verificar
assinatura, exp (30s de tolerância de relógio) e iss.
Sem o segredo:
ENVIRONMENT |
Comportamento |
|---|---|
development (default) |
Aceita o token sem verificar, com log de aviso |
production / prod / staging |
Recusa todo token (401) |
Só HS256 é aceito — permitir RS256 junto abriria confusão de algoritmo, e
alg: none é rejeitado.
⚠️ Gaps conhecidos da integração
full_namenão existe no Keyrunes. É campo só do LMS, gravado na tabela local no registro. Um usuário criado direto pela CLI do Keyrunes ganhafull_name = <parte local do e-mail>no primeiro login.- O registro faz dois round-trips.
POST /api/registerdo Keyrunes não devolve token, então o endpoint registra e em seguida faz login para abrir a sessão. user["user_id"]do token é o id do Keyrunes, nãousers.id. Vários routers antigos passam esse valor direto para colunas que são FK deusers.id. Onde isso importa, use a dependênciaget_current_db_user(app/infra/auth/user_deps.py), que resolve a linha local. Auditar os demais routers continua pendente.
Fórum por curso
Privado do curso: course_access / topic_access / post_access
(app/infra/auth/course_access.py) resolvem quem o caller é em relação ao curso e
devolvem um CourseAccess. O gate roda na leitura também — aluno não
matriculado recebe 403 na própria listagem de tópicos, porque mostrar títulos já
seria vazamento.
| Quem | Pode |
|---|---|
| Aluno matriculado | Ler, abrir tópico, responder, editar/excluir o que é seu, aceitar solução no próprio tópico |
| Instrutor do curso (dono ou co-instrutor) | Tudo acima + fixar, fechar, editar/excluir de qualquer um, responder em tópico fechado |
| Admin | Tudo, sem precisar de matrícula |
| Qualquer outro | 403 |
Regras que valem saber:
- Um nível de aninhamento. Responder a uma resposta achata no pai.
- Uma solução por tópico, garantida por índice unique parcial
(
WHERE is_solution). Aceitar outra move a marca. - Tópico fechado bloqueia o aluno, não a equipe.
reply_countelast_post_atsão denormalizados;touch_topic()recalcula depois de criar ou excluir resposta.- A notificação de resposta é best-effort: o post já está commitado, então falha de e-mail é logada e não propaga.
Instrutores
/instructors é o diretório público: nome, bio, avatar e contagem de cursos
publicados. Nunca devolve e-mail — para isso existe /users, que é admin.
/instructor-applications é o fluxo "seja instrutor": qualquer aluno logado
candidata-se (uma candidatura aberta por pessoa, garantida por índice unique
parcial em status = 'pending'), e o admin aprova ou recusa.
Aprovar promove o papel local para TEACHER. O grupo no Keyrunes precisa ser atribuído por operador — o Keyrunes é o dono da autorização e este serviço não escreve nele:
cargo run --bin cli -- assign-user-to-group \ --username <user> --group-name instructor --namespace public
Carrinho
Uma linha em carts por usuário, com cart_items sob unique
(cart_id, product_id) — adicionar o mesmo produto duas vezes soma quantidade em
vez de criar linha. Carrinho anônimo não tem linha no banco: fica em
localStorage e é enviado para POST /cart/merge no login.
Regras em app/cart/services.py:
| Situação | Resultado |
|---|---|
| Produto não publicado ou gratuito | 400 |
| Curso que o usuário já cursa | 409 |
| Mesmo curso/bundle/membership duas vezes | 409 (quantidade fica em 1) |
| Produto digital repetido | soma a quantidade |
| Cupom que expirou depois de aplicado | descartado na próxima leitura, sem erro |
POST /payments/checkout aceita product_id (compra direta), product_ids
(lista) ou from_cart: true. Um desconto vale para o pedido inteiro, então é
rateado entre as linhas proporcionalmente ao preço, com a última absorvendo o
arredondamento — as linhas somam exatamente o valor cobrado.
As escritas do carrinho commitam explicitamente. A dependência
get_dbsó commita depois de a resposta sair, e sem isso uma leitura imediata via o estado antigo — e um segundo POST rápido furava o unique index em vez de receber 409.
Enums: nome no banco, valor na API
Sete das oito colunas enum persistem o nome do membro (BEGINNER), enquanto o
domínio e a API usam o valor (beginner). A exceção é users.role, que
persiste o valor. Escrever a string crua faz o Postgres recusar o insert:
invalid input value for enum course_level_enum: "beginner"
Por isso toda escrita em coluna enum passa por app/infra/db_enums.to_db_enum,
que aceita membro de enum, nome ou valor e devolve o membro mapeado.
tests/unit/infra/test_models_enums.py compara os labels do modelo com o
CREATE TYPE das migrations e falha se alguém introduzir uma divergência nova.
Papéis no backend
UserRole (app/domain/user/auth_entities.py): ADMIN, TEACHER, STUDENT.
Dependências prontas em app/infra/auth/deps.py: require_admin,
require_teacher (ADMIN+TEACHER), require_student (todos).
get_current_user devolve user_id, email, username, role, groups,
namespace, organization_id e token.