Operação multi-conta¶
Alterna a conta operacional com autorização server-side e faz chat e regeneração usarem esse contexto sem confiar em um tenant informado pelo navegador.
Por que foi construído assim¶
Uma relacao de hierarquia nao e autorizacao. A conta ativa e uma preferencia em cookie HttpOnly, sempre revalidada contra memberships explicitas. Isso impede que um UUID em cookie, body ou header conceda acesso.
Na fronteira com os workers, o Next assina uma assercao curta depois de resolver a sessao e a membership. O worker valida a assinatura e rele a autorizacao no banco antes de executar, preservando revogacao imediata.
Os callers do browser usam um único proxy porque o tenant precisa ser resolvido depois da sessão, no servidor. A troca continua terminando em reload para descartar o estado derivado da conta anterior; o chat recebe um aviso antes da navegação somente para abortar a sessão ou o stream em voo. Esse evento não transporta nem escolhe o novo tenant.
Stack¶
| Camada | Tecnologia |
|---|---|
| Linguagem | TypeScript e Python 3.12 |
| Framework | Next.js 15, React 19 e FastAPI |
| Banco | Supabase PostgreSQL |
| Onde roda | Vercel, Coolify VPS Master e Preview isolado na VPS Dev |
| Servicos externos | Supabase Auth, OpenRouter e Supabase Management API somente no cleanup E2E |
Como funciona¶
flowchart TD
classDef component fill:#D1FAE5,stroke:#10B981,color:#111
classDef flow fill:#DBEAFE,stroke:#3B82F6,color:#111
classDef decision fill:#EDE9FE,stroke:#8B5CF6,color:#111
classDef core fill:#FEE2E2,stroke:#EF4444,color:#111
classDef warning fill:#FEF9C3,stroke:#EAB308,color:#111
subgraph SG_component["Componentes"]
a_selector["Selecao ativa resolvida no servidor"]
a_proxy["Proxy autenticado + `workersApi`"]
a_chat["`ChatIdeaSection`"]
a_dashboard["`HomeDashboard`"]
a_preview["Preview e E2E"]
end
class a_selector,a_proxy,a_chat,a_dashboard,a_preview component
subgraph SG_flow["Fluxo do processo"]
b_login["Usuario seleciona uma membership explicita"]
b_resolve["Next rele autorizacao e resolve a conta ativa"]
b_proxy["Proxy assina o tenant resolvido pelo servidor"]
b_chat["Chat abre sessao e consome resposta em stream"]
b_output["Fila, carrossel final e headline permanecem em B"]
b_switch["Troca aborta requests antigos e recarrega o app"]
b_regen["Dashboard pede outra versao do documento em B"]
b_cleanup["E2E revoga sessoes e remove todas as fixtures"]
end
class b_login,b_resolve,b_proxy,b_chat,b_output,b_switch,b_regen,b_cleanup flow
subgraph SG_decision["Decisões"]
c_membership["Autorizacao atual?"]
c_action["Caller ativo?"]
c_switch["Conta mudou durante o stream?"]
c_accepted["Regeneracao aceita pelo worker?"]
c_preview["Alvo e o Preview isolado no SHA esperado?"]
end
class c_membership,c_action,c_switch,c_accepted,c_preview decision
title["Operacao multi-conta"]
class title core
subgraph SG_warning["Gotchas / Erros"]
w_forbidden["403: membership, sessao ou documento recusado"]
w_abort["Abortar stream antigo; nao reutilizar sessao em B"]
w_regen["Mostrar erro e nao emitir `pipeline:job-created`"]
w_refuse["Recusar Production ou drift de SHA antes das fixtures"]
end
class w_forbidden,w_abort,w_regen,w_refuse warning
title --> b_login
a_selector --> b_login
b_login -->|"revalidar"| c_membership
c_membership -->|"sim"| b_resolve
c_membership -->|"nao"| w_forbidden
b_resolve --> b_proxy
a_proxy --> b_proxy
b_proxy --> c_action
c_action -->|"chat"| b_chat
c_action -->|"regenerar"| b_regen
a_chat --> b_chat
b_chat --> c_switch
c_switch -->|"sim"| w_abort
c_switch -->|"nao: continuar"| b_chat
w_abort -->|"reload"| b_switch
a_dashboard --> b_regen
b_regen --> c_accepted
c_accepted -->|"sim: emitir job"| b_output
c_accepted -->|"nao"| w_regen
b_output -->|"validar"| c_preview
c_preview -->|"sim"| b_cleanup
c_preview -->|"nao"| w_refuse
a_preview --> b_cleanup O seletor lista apenas tenants ativos, nao arquivados, com configuracao e uma membership explicita. Trocar a conta grava cadencia_active_tenant, relido e revalidado em cada requisicao.
Chamadas aos workers passam por /api/app/workers/*. O proxy encaminha a sessao e uma assercao assinada de curta duracao. O worker compara identidade, tenant e papel com o banco antes de permitir leitura ou mutacao.
Na DEV-1845, ChatIdeaSection abre POST /chat/session e consome o SSE de POST /chat/message por workersApi. O wrapper remove Authorization fornecido pelo caller. Se a conta muda, cadencia:active-tenant-will-change aborta sessão e stream antigos antes do reload; um session_id criado em A não é reutilizável em B.
HomeDashboard envia somente content_document_id a POST /pipeline/regenerate, também por workersApi. A interface emite pipeline:job-created apenas após o aceite do worker. Fila, documento final e headline permanecem no tenant resolvido e revalidado no servidor.
Decisões técnicas¶
- Hierarquia nunca concede acesso;
user_tenant_rolesautoriza. - O cookie registra preferencia, nao autoridade.
- O browser nao envia
tenant_idcomo credencial nem recebe o segredo. - O worker rele membership e arquivamento em cada requisicao.
- O mesmo proxy atende JSON e stream SSE;
responseType: "response"preserva a resposta bruta sem contornar autenticação. - O evento anterior ao reload serve somente para cancelamento local; não escolhe nem transporta tenant.
- Regeneração só anuncia um job depois do aceite; rejeição não cria polling otimista.
- Rollout e worker-first porque o Coolify nao possui auto-deploy.
- Onboarding multi-conta pertence a DEV-1855 e nao pode ser simulado no banco.
Esses contratos estendem a arquitetura das DEV-1843 e DEV-1844. A DEV-1845 não cria ADR novo.
Gotchas & armadilhas¶
- Subconta existe mas nao aparece - o provisionamento administrativo cria zero memberships.
- Selecao volta para a mesma tela - a API pode retornar
200, mas/appdevolve307quando o onboarding da filha esta pendente e a identidade possui varias memberships. - Frontend e worker divergentes - publicar o Next nao atualiza o Coolify; o worker exige deploy manual e paridade do segredo.
- Super Admin nao equivale a Owner - o seletor comum continua dependendo de membership explicita.
- Documentacao antiga - a story tenant-aware de onboarding e DEV-1855, nao DEV-1853.
- Chamar
/api/v1/*do browser - contorna o proxy; callers de UI usamworkersApipara alcançar/api/app/workers/*. - Reutilizar sessão após a troca - o ID é tenant-scoped e retorna
403 SESSION_NOT_FOUNDfora da conta original. - Sinalizar regeneração antes do aceite - cria polling fantasma; o evento
pipeline:job-createdpertence somente ao caminho aceito. - Uma credencial de Preview no lugar da outra -
OPENROUTER_API_KEYatende o chat;OPENAI_API_KEYé lida pelo cliente unificado de research, seleção, headline, carousel e caption. As duas são obrigatórias. - Token de cleanup no runtime -
DEV1845_SUPABASE_MANAGEMENT_TOKENexiste apenas no processo local do E2E; nunca entra emruntime.envou containers.
Como operar¶
# Deployment do frontend
vercel inspect dpl_BxYQQ5GxUgowW2fQbZQdwVFkeG72
# Requisicoes recentes
vercel logs dpl_BxYQQ5GxUgowW2fQbZQdwVFkeG72 --since 30m --json
# Testes focados
npm test -- src/lib/supabase/tenant.test.ts src/app/api/app/active-tenant/route.test.ts
# Callers chat e regeneração
npm run test:dev1845
# Contratos do harness DEV-1845, sem rede
npm run qa:dev1845:e2e -- --self-test
O E2E real adquire o mesmo deploy.lock do deploy/rollback, recusa Production, exige SHA exato, cria fixtures com prefixo e2e-dev1845-*, dirige chat e regeneração pela UI, revoga as sessões QA e audita resíduo em todas as tabelas. As guardas de histórico impedem o cascade comum de tenant_editorials; por isso o cleanup usa a Management API somente para SQL limitado aos UUIDs da execução, restaura os triggers e então remove os tenants pelo fluxo comum.
Validação¶
No Preview isolado, o SHA 9e66c148131c7dc8cdfbb18b79e3abb4114c7f3a passou com chat e stream somente em A; troca durante o stream; rejeição da sessão de A em B; regeneração completa somente em B, incluindo fila, carrossel final e headline; documento de A recusado em B com 404 NOT_FOUND; membership revogada bloqueando os dois callers; sessões revogadas, inventário final zero e SHA estável.
Essa evidência vale para a Preview e para esse SHA. Não comprova deploy em produção e não autoriza merge da PR #414.
FAQ¶
A filha aparece automaticamente para usuarios da conta-mae? Nao. Apenas uma membership explicita concede acesso.
Por que uma subconta pendente retorna para a selecao? O onboarding legado ainda nao usa a conta ativa com seguranca. O layout bloqueia o caminho ate a DEV-1855 para evitar gravacoes no tenant errado.
A DEV-1844 termina quando o frontend e publicado? Nao. O worker precisa estar compativel, com segredo pareado, e o smoke cross-stack deve passar.
Posso marcar o onboarding como concluido para testar? Nao. Isso esconderia preparacao incompleta e deixaria a conta em estado parcial.
O browser pode enviar o UUID da conta selecionada ao worker? Não. O Next resolve o tenant usando sessão, cookie revalidado e membership atual; o worker relê a autorização antes de executar.
Por que abortar o chat se a página será recarregada? O aborto impede que um stream da conta antiga continue produzindo estado local na janela entre a seleção e a navegação.
Por que a regeneração usa OPENAI_API_KEY se o endpoint é OpenRouter? Esse é o nome lido pelo cliente unificado do pipeline. O chat lê OPENROUTER_API_KEY diretamente; uma variável não substitui a outra.
O token da Supabase Management API é necessário para o app? Não. Ele é exclusivo do cleanup do E2E.
Referencias¶
- Issues: DEV-1843, DEV-1844, DEV-1845, DEV-1846 e DEV-1855.
- Fonte tecnica:
cadencia-app/docs/features/active-tenant-selection.md. - Contrato worker:
cadencia-app/docs/features/worker-tenant-context.md. - Preview e cleanup:
cadencia-app/docs/infra/preview-vps-dev.md. - Canvas fonte do diagrama:
Projetos/Cadencia/operacao-multi-conta.canvas. - Hierarquia multi-conta.