Install Flow (OAuth-like)
TL;DR O PopinaFlow não implementa OAuth2 standard com redirect/code/exchange. Usamos um fluxo install-then-token mais simples: o admin do tenant aprova escopos diretamente no painel, e o backend emite um
accessTokenBearer per-installation.
Por que não OAuth2 RFC-6749 puro
OAuth2 padrão presume que o usuário vive na sua App e é redirecionado ao provider para consentir. No caso do PopinaFlow o oposto é mais natural: o tenant já está logado no painel administrativo do PopinaFlow quando decide instalar sua App. O fluxo de redirect→callback adiciona atrito sem ganho de segurança nesse contexto.
Resultado: o consentimento acontece in-app (no /t/:slug/admin/marketplace) e o token cruza para a App publisher uma única vez, copy-once, sem callback HTTP.
A reintrodução de OAuth2 padrão pode acontecer em uma v2 da API se aparecer demanda real de Apps single-page baseadas em browser. Hoje, todas as integrações são server-to-server.
Atores e termos
| Termo | Significado |
|---|---|
| App | Documento marketplace_apps com slug, clientId, clientSecretHash, scopes[], webhookUrl. Visível para tenants quando status='published'. |
| Installation | Documento marketplace_app_installations que liga tenant ↔ app com scopes[] (subset dos escopos da App), accessTokenHash, signingSecret, status. |
accessToken | String hex de 128 chars (64 bytes random). Bearer token enviado em Authorization: Bearer <token> para /public/v1/*. Armazenado como bcrypt(rawToken, cost=12). |
signingSecret | String hex de 64 chars (32 bytes random) usada para assinar webhooks via HMAC-SHA256. Per-installation, mantido server-side, entregue ao publisher fora-de-banda no onboarding (veja webhooks.md). |
clientId / clientSecret | Identidade do publisher. clientId é UUID v4 público; clientSecret é mostrado UMA vez na criação da App e armazenado como bcrypt. Não é usado em runtime hoje — reservado para v2. |
Endpoints do install flow
Todas as rotas abaixo são tenant-scoped (vivem sob /t/:slug/admin/marketplace) e exigem que o usuário esteja autenticado no painel com role admin ou superadmin. Guard chain: JwtAuthGuard → TenantGuard → RolesGuard.
Listar apps publicadas
GET /t/:slug/admin/marketplace/apps
Authorization: Bearer <jwt-do-admin>Retorna App[] com status='published'. O hash clientSecretHash é stripped da resposta.
Detalhe de uma App
GET /t/:slug/admin/marketplace/apps/:appSlug
Authorization: Bearer <jwt-do-admin>404 para Apps em draft ou deprecated — só publicadas são visíveis.
Listar installations ativas do tenant
GET /t/:slug/admin/marketplace/installations
Authorization: Bearer <jwt-do-admin>Retorna Installation[] com status='active', populadas com o documento App. Nunca retorna accessTokenHash nem signingSecret.
Instalar uma App
POST /t/:slug/admin/marketplace/apps/:appSlug/install
Authorization: Bearer <jwt-do-admin>
Content-Type: application/json
{
"scopes": ["orders.read", "webhooks.subscribe"]
}Validações server-side (em AppInstallService.installApp):
- App existe e está em
status='published'. Caso contrário →400. requestedScopes ⊆ App.scopes. Pedir escopo não declarado pela App →400.- Não existe instalação
activedesse{tenant, app}. Caso exista →409 Conflict(precisa revogar antes de reinstalar).
Resposta de sucesso (201):
{
"installation": {
"_id": "65f1c8a2e4b0a8d1e5c3b9a7",
"tenant": "65a0b1c2d3e4f5a6b7c8d9e0",
"app": "65a0b1c2d3e4f5a6b7c8d9e1",
"installedBy": "65a0b1c2d3e4f5a6b7c8d9e2",
"scopes": ["orders.read", "webhooks.subscribe"],
"status": "active",
"createdAt": "2026-05-23T14:21:00.000Z"
},
"accessToken": "9b7f6e5d4c3b2a190f8e7d6c5b4a3928...128 hex chars total"
}CRÍTICO — token mostrado UMA VEZ. O campo
accessTokenretorna em texto plano apenas nesta resposta. O servidor armazena somentebcrypt(rawToken, cost=12)emaccessTokenHash. Não há endpoint para recuperá-lo depois. Frontend de consent modal deve apresentar UI copy-once e avisar o usuário antes de fechar.
A installation retornada não inclui accessTokenHash nem signingSecret — esses ficam restritos ao servidor.
Revogar uma installation
DELETE /t/:slug/admin/marketplace/installations/:id
Authorization: Bearer <jwt-do-admin>Marca status='revoked' e gravando revokedAt. Idempotente — revogar algo já revogado é no-op (não rebumpa revokedAt). Após revogação, o accessToken correspondente para de validar imediatamente (no próximo bcrypt-compare ele falha porque a query do guard só considera installations ativas; veja S3 em public-api-v1.md).
A revogação também pára entregas de webhook: o eligibility check em WebhookDeliveryService.enqueueDelivery exige installation.status === 'active'.
Usando o accessToken para chamar a API pública
Em cada chamada para /public/v1/*:
GET /public/v1/orders HTTP/1.1
Host: popinaflow.alojaweb.online
Authorization: Bearer 9b7f6e5d4c3b2a190f8e7d6c5b4a3928...Estado atual da auth (v1)
Importante (Maio 2026): o
/public/v1/*hoje aceita o header legadox-api-key: <raw>(chave por-usuário, gap S3 empublic-api-v1.md). A migração para Bearer per-installation é o caminho oficial — Apps novas devem ser construídas assumindo Bearer. O headerx-api-keyserá descontinuado com 90 dias de aviso quando oApiKeyGuardfor substituído pelo guard de installation.
Resumo do que você precisa saber HOJE:
| Cenário | Header |
|---|---|
| App nova (recomendado, contrato futuro) | Authorization: Bearer <accessToken> |
| App legada já provisionada antes do marketplace | x-api-key: <raw> + opcionalmente x-tenant-id: <objectId> (multi-tenant) |
A semântica de tenant é resolvida automaticamente pelo token: cada installation pertence a exatamente um tenant.
Como o tenant te revoga
Há duas formas pelo lado do tenant:
- UI —
/t/:slug/admin/marketplace→ aba "Instaladas" → botão "Revogar acesso". UsauseUndoToast(5s de janela de desfazer) e disparaDELETEna rota acima. - API direta — admin/superadmin chama o
DELETEpor integração própria (raro, mas suportado para automações de gestão).
Sua App deve lidar com 401 Unauthorized em respostas a partir desse momento — significa que o tenant te baniu. Mostre uma mensagem clara no seu lado ("este restaurante revogou o acesso") e remova a installation do seu mapeamento interno.
Como o publisher te bane (kill-switch)
Em caso de violação de termos (vazamento de dados, abuso de escopo, etc.), a equipe PopinaFlow tem um kill-switch que define App.status='deprecated' ou marca todas as installations da App como revoked em batch. Comunicação aos tenants afetados acontece em <2h (canal: ticket de suporte + email).
Recomendação operacional para publishers: monitore 401 em sua telemetria — um spike concentrado provavelmente é kill-switch.
Boas práticas
- Não armazene
accessTokenem texto claro. Use um vault (HashiCorp Vault, AWS Secrets Manager, Doppler, env-vars criptografadas). Trate o token como senha. - Não logue o
accessToken. Truque comum: ao logarAuthorization, redacte comBearer ***. - Cache resultados onde fizer sentido. Endpoints de leitura (orders, menu) são throttle-limited; respeite 60s de cache para listagens grandes (
menu/items). - Use TLS 1.2+ no seu
webhookUrl. Endpoints HTTP plain são aceitos hoje mas serão rejeitados na revisão futura. - Implemente retry idempotente em chamadas POST — em caso de timeout do PopinaFlow, retentar pode duplicar — use chave de idempotência interna sempre que criar entidade (ex: reservation).