1 parent
6e23f1533b
commit
419325db12
52 files changed
+1319
-100
No files matched your search
+13
-5
@@ -19,18 +19,26 @@ SENTRY_RELEASE=
|
|||||||
|
|
||||||
# Login central do ecossistema. O Adaptador não tem formulário de senha.
|
# Login central do ecossistema. O Adaptador não tem formulário de senha.
|
||||||
ELO_PRO_LOGIN_URL=
|
ELO_PRO_LOGIN_URL=
|
||||||
|
# URL pública deste aplicativo. Usada para montar returnTo no mesmo host.
|
||||||
|
ELO_PRO_APP_URL=
|
||||||
|
# Hosts adicionais aceitos no returnTo, separados por vírgula. Caminho relativo sempre é preferido.
|
||||||
|
AUTH_ALLOWED_RETURN_HOSTS=
|
||||||
|
|
||||||
# Cookie de workspace ativo definido pela plataforma Elo Pro.
|
# Cookie de workspace ativo definido pela plataforma Elo Pro.
|
||||||
# Nome lido pelo servidor: elo_pro_workspace_id
|
# Nome lido pelo servidor: elo_pro_workspace_id
|
||||||
|
|
||||||
# TTL de fontes e saídas, em segundos. Decisão de produto ainda aberta.
|
# TTL de fontes e saídas, em segundos. Obrigatório em produção. Inteiro de 1 a 604800.
|
||||||
# Obrigatório em produção. Em dev/teste, a ausência usa apenas um valor provisório de código.
|
# Em dev/teste, a ausência usa 3600. O número de produto continua configurável.
|
||||||
SPREADSHEET_TEMP_TTL_SECONDS=
|
SPREADSHEET_TEMP_TTL_SECONDS=
|
||||||
|
|
||||||
# inline = processa no mesmo processo após a resposta (dev). worker = só enfileira.
|
# Tempo máximo de um processamento, em milissegundos. Ausente usa 120000.
|
||||||
ADAPTER_JOB_MODE=inline
|
SPREADSHEET_JOB_TIMEOUT_MS=
|
||||||
|
|
||||||
|
# inline = processa no mesmo processo (somente dev/teste). worker = enfileira no Redis e no Postgres.
|
||||||
|
# Em produção, inline é recusado.
|
||||||
|
ADAPTER_JOB_MODE=worker
|
||||||
|
|
||||||
# Sessão local SEM senha, só fora de produção, para piloto sem o host Elo Pro.
|
# Sessão local SEM senha, só fora de produção, para piloto sem o host Elo Pro.
|
||||||
# Não cria usuário, hash ou refresh token proprietário.
|
# Produção recusa ADAPTER_DEV_AUTH=1.
|
||||||
ADAPTER_DEV_AUTH=0
|
ADAPTER_DEV_AUTH=0
|
||||||
ADAPTER_DEV_AUTH_SECRET=
|
ADAPTER_DEV_AUTH_SECRET=
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# ADR 0004 — TTL de temporários
|
# ADR 0004 — TTL de temporários
|
||||||
|
|
||||||
Status: aberta.
|
Status: política operacional definida; o número de produto continua configurável.
|
||||||
|
|
||||||
`SPREADSHEET_TEMP_TTL_SECONDS` é obrigatório em produção e aceita de 1 segundo a 7 dias para não gravar um número de produto no código. Fora de produção, a omissão usa 3600 segundos só para o ambiente local. A limpeza não apaga chaves que contenham `/models/`.
|
`SPREADSHEET_TEMP_TTL_SECONDS` é a política única. Em produção é obrigatório e o processo recusa subir sem um inteiro entre 1 e 604800. Fora de produção, a omissão usa 3600 segundos. A limpeza compara `expires_at` com o relógio injetado, é idempotente, só apaga chaves `workspaces/{workspaceId}/tmp/` e não interrompe o processamento se um delete falhar. O valor numérico aprovado pelo negócio ainda não foi fixado; o piloto define o número na configuração, não no código.
|
||||||
@@ -2,8 +2,18 @@
|
|||||||
|
|
||||||
## Azul de ação
|
## Azul de ação
|
||||||
|
|
||||||
O escopo fixa Bright Blue `#2F8FFF` e pede o azul primário do token oficial, que não estava no repositório. Uso provisório: `#0B3D91` em `--color-action`, com texto branco. Substituir quando o pacote do Design System Elo Pro for ligado.
|
O escopo fixa Bright Blue `#2F8FFF` e pede o azul primário do token oficial, que não estava no repositório. Uso provisório: `#0B3D91` em `--color-action`, com texto branco. Esse valor não é token oficial e não deve ser promovido por conveniência. Substituir somente quando o pacote do Design System Elo Pro for disponibilizado.
|
||||||
|
|
||||||
## Exclusão
|
## Exclusão
|
||||||
|
|
||||||
Status: aberta. O schema marca `deleted` e preenche `deleted_at`, mantém o arquivo e o histórico. A exclusão física pode ser feita depois sem migração destrutiva. Enquanto houver job em `validating`, `ready`, `queued` ou `processing`, a exclusão é recusada.
|
Status: provisória e centralizada em `src/domain/spreadsheet-adapter/retention-policy.ts`.
|
||||||
|
|
||||||
|
| Entidade | Tratamento |
|
||||||
|
| --- | --- |
|
||||||
|
| Configuração de mapeamento | Permanece com o job |
|
||||||
|
| Modelo | Soft delete (`deleted` + `deleted_at`); arquivo retido |
|
||||||
|
| Fonte e resultado | Hard delete do blob após o TTL |
|
||||||
|
| Job | Metadados permanecem |
|
||||||
|
| Auditoria | Append-only; `app_user` não recebe update nem delete |
|
||||||
|
|
||||||
|
A exclusão física do modelo pode ser feita depois sem migração destrutiva. Enquanto houver job em `validating`, `ready`, `queued` ou `processing`, a exclusão do modelo é recusada. O prazo dos temporários continua em `SPREADSHEET_TEMP_TTL_SECONDS`.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Revisão final contra o escopo V3
|
# Revisão final contra o escopo V3
|
||||||
|
|
||||||
|
Status consolidado: **Pilot Ready in Code / Environment Validation Pending**. O gate, a ordem da validação no ambiente Elo Pro e a regra de manter a feature flag desligada estão em `18-pilot-final-gate.md`.
|
||||||
|
|
||||||
| Requisito | Status | Evidência |
|
| Requisito | Status | Evidência |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Sem login, senha ou token próprio | OK | `src/server/auth`, ausência de tabela de senha |
|
| Sem login, senha ou token próprio | OK | `src/server/auth`, ausência de tabela de senha |
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Auditoria de prontidão do piloto
|
||||||
|
|
||||||
|
Estado encontrado antes do hardening desta entrega. O resultado depois das mudanças está em `18-pilot-final-gate.md`.
|
||||||
|
|
||||||
|
Nenhum repositório real da Elo Pro estava disponível. Os nomes `profiles`, `workspaces` e `workspace_members` são o contrato local, não um schema confirmado.
|
||||||
|
|
||||||
|
| Item | Classificação | Estado | Evidência | Risco | Dependência externa | Decisão | Módulo | Ação | Severidade |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1. Autenticação Supabase | NEEDS INTEGRATION | `getUser` quando URL e anon key existem. Sem tabela de senha. | `src/server/auth/context.ts` | Piloto sem o projeto Supabase da Elo Pro não autentica de verdade | Projeto Supabase Elo Pro | Não criar identidade paralela | `src/server/auth` | Ligar as envs e validar login real | Alta |
|
||||||
|
| 2. Workspace | READY WITH CONFIGURATION | Cookie `elo_pro_workspace_id` validado por membership no servidor | `context.ts` | Cookie de outro ambiente aponta workspace errado | Cookie emitido pela Elo Pro | Confirmar o nome do cookie no host real | `context.ts` | Testar troca de workspace no host | Alta |
|
||||||
|
| 3. RLS | READY WITH CONFIGURATION | Policies e `FORCE RLS` no shim. Testes usam PGlite com `app_user` | `migrations/0003_rls.sql`, `tests/integration/rls.test.ts` | Postgres real pode divergir do PGlite | Postgres Elo Pro | Não aplicar o shim se as tabelas já existirem com outro formato | `migrations/` | Rodar o teste de isolamento no banco do piloto | Alta |
|
||||||
|
| 4. Papéis | READY | owner, admin, gestor, agent. Superadmin local não ignora o módulo | `permissions.ts` | Papéis reais da Elo Pro podem ter outros nomes | Membership real | Mapear papéis quando o schema for confirmado | `permissions.ts` | Manter o adaptador | Média |
|
||||||
|
| 5. Feature flag | READY WITH CONFIGURATION | `spreadsheet_adapter` nasce desligada | `workspace_module_flags`, `scripts/set-flag.ts` | Ligar para todos os workspaces | Operação | Só o workspace piloto | flag | Ativar no passo final do runbook | Alta |
|
||||||
|
| 6. Storage | NEEDS INTEGRATION | Interface privada. Produção exige bucket. Download passa pela rota autenticada | `src/infra/storage` | Bucket público ou URL permanente | Bucket Supabase | Manter privado | `storage/supabase.ts` | Criar bucket privado | Alta |
|
||||||
|
| 7. Jobs | READY WITH CONFIGURATION | Estados duráveis no Postgres. Inline só fora de produção | `service.ts`, `process-job.ts` | Piloto subir em inline | Worker | `ADAPTER_JOB_MODE=worker` | `scripts/worker.ts` | Subir o worker | Alta |
|
||||||
|
| 8. Redis | NEEDS INTEGRATION | Lock e fila de IDs. Sem Redis a produção recusa o processamento | `src/infra/redis` | Várias instâncias sem Redis processam em duplicidade | Redis | Obrigatório no piloto | `redis.ts` | Preencher `REDIS_URL` | Alta |
|
||||||
|
| 9. Locks | READY | Lock por usuário, TTL 120s, liberação no `finally` | `lockKey` | Lock em memória não atravessa processos | Redis no piloto | Não usar lock global | `redis.ts` | Usar Redis | Média |
|
||||||
|
| 10. TTL | READY WITH CONFIGURATION | Obrigatório em produção. Dev usa 3600 | `src/config/retention.ts`, ADR 0004 | Temporários eternos se a env faltar | Número operacional | Não gravar o número no código | `retention.ts` | Definir a env no piloto | Alta |
|
||||||
|
| 11. Limpeza | READY | `cleanupExpired` ignora `/models/` e é idempotente | `process-job.ts` | Falha de storage deixa blob órfão | Storage | Falha de limpeza não pode derrubar o job | worker | Observar `CLEANUP_FAILED` | Média |
|
||||||
|
| 12. Auditoria | READY | Eventos sem células. Append-only para `app_user` | `scrub.ts`, `0004_audit_append_only.sql` | Service role ainda escreve | Nenhuma | Não apagar auditoria no rollback | `audit_log` | Manter | Média |
|
||||||
|
| 13. Sentry | READY WITH CONFIGURATION | Sem DSN fica inerte. Payload descarta texto livre | `scrub.ts`, `capture.ts` | DSN ausente deixa o piloto cego | Projeto Sentry | Não anexar arquivo | `instrumentation.ts` | Configurar DSN | Média |
|
||||||
|
| 14. XLSX | NEEDS DECISION | ExcelJS 4.4.0 gera workbook novo. O modelo original não é reescrito | `parse.ts`, ADR 0005 | Formatação do modelo não é preservada | Nenhuma para o MVP | Manter ExcelJS no piloto | `13-xlsx-library-review.md` | Aceitar a recomendação | Média |
|
||||||
|
| 15. CSV | READY | UTF-8, delimitador, neutralização na saída | `csv.ts`, `csv-injection.ts` | Encoding legado é recusado | Nenhuma | UTF-8 apenas | `parse.ts` | Manter | Baixa |
|
||||||
|
| 16. PDF | READY | Gerado sob demanda, truncado em 200 linhas e 12 colunas | `pdf.tsx` | Usuário espera o arquivo inteiro | Nenhuma | Truncamento já documentado | `pdf.tsx` | Informar o limite | Baixa |
|
||||||
|
| 17. Exclusão | NEEDS DECISION | Soft delete de modelo. Blob temporário sai com o TTL | ADR 0006 | Reter planilha além do necessário | Negócio | Política provisória configurável | `retention-policy.ts` | Não fazer hard delete de auditoria | Média |
|
||||||
|
| 18. Migrations | READY WITH CONFIGURATION | 0001 é shim. 0002–0004 são do Adaptador | `migrations/` | Aplicar 0001 em cima do schema real da Elo Pro | Schema real | 0001 só se as tabelas não existirem | `identity-port.ts` | Conferir o banco antes do migrate | Alta |
|
||||||
|
| 19. Variáveis | NEEDS INTEGRATION | `.env.example` lista o conjunto. A validação recusa produção incompleta | `src/config/env.ts` | Build de CI não tem os segredos e não deve falhar | Secrets do piloto | Falhar no start, não no build | `15-production-environment.md` | Preencher no ambiente | Alta |
|
||||||
|
| 20. Dev versus produção | READY WITH CONFIGURATION | `ADAPTER_DEV_AUTH` exige não-produção | `dev-session.ts` | Segredo de dev vazado para produção | Nenhuma | Produção recusa a flag | `env.ts` | Não definir o segredo no piloto | Alta |
|
||||||
|
|
||||||
|
## Pilot Go-Live Blockers
|
||||||
|
|
||||||
|
Estes itens impedem ativar o primeiro workspace piloto. O restante é preparação já coberta no código ou decisão que não bloqueia se a recomendação for aceita.
|
||||||
|
|
||||||
|
1. O banco e o Auth reais da Elo Pro não estão ligados. O shim de identidade não pode ser aplicado se as tabelas já existirem com outro formato.
|
||||||
|
2. O bucket privado de storage não está criado nem configurado.
|
||||||
|
3. Redis e o worker não estão em execução no ambiente do piloto. Inline fica recusado em produção.
|
||||||
|
4. `SPREADSHEET_TEMP_TTL_SECONDS` ainda não tem valor no ambiente de piloto.
|
||||||
|
5. O smoke com Supabase, Redis e bucket reais não foi executado.
|
||||||
|
6. A feature flag do workspace piloto continua desligada, de propósito, até o fim do runbook.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Integração de identidade Elo Pro
|
||||||
|
|
||||||
|
O Adaptador não cria usuário, senha, cadastro ou papel próprio. A sessão de produção é a do Supabase Auth. O workspace ativo chega no cookie `elo_pro_workspace_id` e é conferido no servidor contra a membership.
|
||||||
|
|
||||||
|
## Contexto autenticado
|
||||||
|
|
||||||
|
`resolveAuth` devolve `AuthenticatedContext`:
|
||||||
|
|
||||||
|
- `userId`
|
||||||
|
- `workspaceId`
|
||||||
|
- `role`
|
||||||
|
- `permissions` (`adapter.session`, `adapter.use`, `adapter.manage`, `adapter.upload`)
|
||||||
|
- `moduleEnabled`
|
||||||
|
|
||||||
|
APIs passam por `requireReady`. Serviços chamam `assertUse`, que relê a flag no banco. Um `workspaceId` no corpo não substitui o cookie validado. `rejectWorkspaceForgery` registra a tentativa.
|
||||||
|
|
||||||
|
## Contrato ainda não confirmado
|
||||||
|
|
||||||
|
`src/server/auth/identity-port.ts` lista as tabelas que o código lê hoje. O status é `unconfirmed_against_real_elo_pro`, exceto a flag do módulo, que é específica do Adaptador. Quando o repositório da Elo Pro estiver acessível, a adaptação acontece nesse ponto. Não há migration definitiva da Elo Pro neste repositório.
|
||||||
|
|
||||||
|
## Desenvolvimento
|
||||||
|
|
||||||
|
`ADAPTER_DEV_AUTH=1` só funciona com `NODE_ENV` diferente de `production` e com `ADAPTER_DEV_AUTH_SECRET`. Em produção a validação de ambiente recusa a flag, e `devAuthEnabled()` permanece falso.
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
- Workspace de outro usuário não lista nem baixa o arquivo.
|
||||||
|
- `workspaceId` forjado é recusado.
|
||||||
|
- Usuário sem membership não opera.
|
||||||
|
- Módulo desligado bloqueia mesmo que o contexto do cliente diga o contrário.
|
||||||
|
- Produção ignora a sessão de desenvolvimento.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Topologia de sessão
|
||||||
|
|
||||||
|
O Adaptador não implementa IdP nem OAuth próprio e não copia cookie entre domínios.
|
||||||
|
|
||||||
|
## Cenários
|
||||||
|
|
||||||
|
| Cenário | Comportamento |
|
||||||
|
| --- | --- |
|
||||||
|
| Mesmo domínio, subpaths | Cookie Supabase e `elo_pro_workspace_id` já visíveis. `/login` só redireciona se a sessão não existir |
|
||||||
|
| Subdomínios | Exige cookie no domínio pai, configurado na Elo Pro. O Adaptador não redefine o domínio do cookie |
|
||||||
|
| Domínios separados | O login central emite a sessão Supabase. O retorno usa `returnTo` apenas para host permitido |
|
||||||
|
| Redirect central | `ELO_PRO_LOGIN_URL` recebe `returnTo` |
|
||||||
|
| Sessão Supabase compartilhada | `getUser` lê os cookies `sb-*` já presentes |
|
||||||
|
|
||||||
|
## returnTo
|
||||||
|
|
||||||
|
`safeReturnPath` aceita somente caminho relativo interno. `https://`, `//` e host fora de `ELO_PRO_APP_URL` / `AUTH_ALLOWED_RETURN_HOSTS` voltam para `/adaptador`.
|
||||||
|
|
||||||
|
`/login` em produção redireciona para o login central. A tela local com identidades de desenvolvimento só aparece quando `ADAPTER_DEV_AUTH` está ligado fora de produção.
|
||||||
|
|
||||||
|
## Sessão e módulo
|
||||||
|
|
||||||
|
- Sem cookie de sessão: anônimo, 401 nas APIs.
|
||||||
|
- Cookie presente e usuário ausente: `session_expired`, mensagem para entrar de novo.
|
||||||
|
- Autenticado sem membership ou sem workspace: bloqueio, sem operar planilha.
|
||||||
|
- Módulo desligado: rotas voltam ao painel e APIs respondem `FEATURE_DISABLED`.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Job runner
|
||||||
|
|
||||||
|
Postgres guarda o estado. Redis guarda o lock por usuário e uma fila de IDs. O payload da fila é somente o id do job.
|
||||||
|
|
||||||
|
Estados já existentes e suficientes: `draft`, `validating`, `ready`, `queued`, `processing`, `completed`, `failed`, `expired`, `cancelled`.
|
||||||
|
|
||||||
|
## Modo
|
||||||
|
|
||||||
|
`ADAPTER_JOB_MODE=worker` enfileira. `inline` só é aceito fora de produção. Produção com `inline` falha na validação de ambiente e também em `startProcess`.
|
||||||
|
|
||||||
|
Falha transitória (`5xx`, exceto timeout e erros de arquivo) volta para `queued` até 3 tentativas. Timeout, validação e modelo alterado não entram em loop. O lock é liberado no `finally`. Job sem lock em `processing` é recuperado por `recoverStaleJobs`.
|
||||||
|
|
||||||
|
## Subir o piloto
|
||||||
|
|
||||||
|
1. Redis acessível em `REDIS_URL`.
|
||||||
|
2. `npm run start` para a aplicação web, com as envs de produção.
|
||||||
|
3. `npm run worker` em outro processo, com as mesmas envs de banco, storage e Redis.
|
||||||
|
|
||||||
|
A limpeza de temporários roda no tick do worker. Se ela falhar, o tick seguinte continua e o erro segue para o capturador com o código `CLEANUP_FAILED`, sem conteúdo de planilha.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Revisão da biblioteca XLSX
|
||||||
|
|
||||||
|
## Atual
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
| --- | --- |
|
||||||
|
| Biblioteca | ExcelJS |
|
||||||
|
| Versão declarada | 4.4.0 |
|
||||||
|
| Licença | MIT |
|
||||||
|
| Leitura | `workbook.xlsx.load`, valor exibido em `cell.text` |
|
||||||
|
| Escrita | Novo workbook, aba `Adaptado`, texto com formato `@` |
|
||||||
|
|
||||||
|
## Vantagens
|
||||||
|
|
||||||
|
MIT, já integrada, lê e escreve XLSX, não executa macro e não avalia fórmula. O arquivo modelo permanece o blob original; o processamento grava outra chave.
|
||||||
|
|
||||||
|
## Limitações
|
||||||
|
|
||||||
|
Não preserva estilo, mesclagem, largura de coluna nem as outras abas do modelo no arquivo de saída. Datas e números saem como texto exibido. PDF continua truncado à parte. Arquivo protegido por senha cai em erro de estrutura.
|
||||||
|
|
||||||
|
## Riscos
|
||||||
|
|
||||||
|
Trocar a biblioteca no piloto reabre a suíte de mapeamento sem ganho para o contrato atual: a saída é um artefato novo e o modelo é imutável.
|
||||||
|
|
||||||
|
## Alternativas
|
||||||
|
|
||||||
|
SheetJS Community tem restrição de licença que exigiria revisão jurídica. Manter ExcelJS atrás de `readSpreadsheet` e `buildXlsx` permite trocar depois sem mudar o domínio.
|
||||||
|
|
||||||
|
## Custo de migração
|
||||||
|
|
||||||
|
Alto para o piloto: fixtures, tipos, datas e o contrato de “não executar fórmula” teriam de ser revalidados. Não há blocker que obrigue a troca.
|
||||||
|
|
||||||
|
## Recomendação
|
||||||
|
|
||||||
|
Manter ExcelJS 4.4.0 no piloto. Não criar uma segunda engine. Fixtures em `tests/unit/xlsx-fixtures.test.ts` cobrem várias abas, fórmula como texto, data, caractere especial e a imutabilidade do modelo.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Isolamento multi-tenant
|
||||||
|
|
||||||
|
Invariantes:
|
||||||
|
|
||||||
|
1. O workspace da operação é o do contexto resolvido no servidor.
|
||||||
|
2. RLS com `app_user` esconde linha de outro workspace e de outro usuário no mesmo workspace.
|
||||||
|
3. A flag do módulo é relida no banco em listagem, processamento e download.
|
||||||
|
4. Download só lê chave `workspaces/{workspaceId}/tmp/`.
|
||||||
|
5. Limpeza só apaga chave com o prefixo do workspace dono da linha.
|
||||||
|
6. Auditoria não é atualizada pelo papel da aplicação.
|
||||||
|
7. Texto de célula não entra em auditoria nem no payload enviado ao Sentry.
|
||||||
|
|
||||||
|
A suíte `tests/security/isolation.test.ts` e `tests/integration/rls.test.ts` cobre workspace A/B, admin com módulo desligado, agente sem o job de outro usuário, flag desligada depois do processamento, `workspaceId` de quem não é membro, lock concorrente e arquivo hostil.
|
||||||
|
|
||||||
|
URL assinada permanente não é emitida. O download é a rota autenticada. Reutilizar um id de outro workspace devolve `NOT_FOUND` ou `FEATURE_DISABLED`.
|
||||||
|
|
||||||
|
O que ainda depende do Postgres e do Storage reais está marcado no gate final.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Ambiente de produção
|
||||||
|
|
||||||
|
A validação está em `src/config/env.ts`. Ela roda no start da aplicação e do worker. Durante `next build` ela não interrompe o CI, porque o build não tem os segredos do piloto.
|
||||||
|
|
||||||
|
| Variável | Obrigatória | Ambiente | Finalidade | Exemplo sem segredo | Risco se ausente |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| NEXT_PUBLIC_SUPABASE_URL | Sim | Produção | Auth Elo Pro | `https://<projeto>.supabase.co` | Ninguém autentica |
|
||||||
|
| NEXT_PUBLIC_SUPABASE_ANON_KEY | Sim | Produção | Chave pública do Auth | `anon` | Sessão não resolve |
|
||||||
|
| SUPABASE_SERVICE_ROLE_KEY | Sim | Servidor | Storage e tarefas internas | `service-role` | Upload e worker param |
|
||||||
|
| SUPABASE_STORAGE_BUCKET | Sim | Produção | Bucket privado | `spreadsheet-adapter` | Produção recusa storage. Valor `public` é recusado |
|
||||||
|
| DATABASE_URL | Sim | Produção | Postgres | `postgres://host/db` | Aplicação não sobe |
|
||||||
|
| REDIS_URL | Sim | Produção | Lock e fila de ids | `redis://host:6379` | Processamento recusado |
|
||||||
|
| SENTRY_DSN | Sim | Produção | Erros sanitizados | `https://<key>@sentry/<id>` | Piloto sem alerta |
|
||||||
|
| SPREADSHEET_TEMP_TTL_SECONDS | Sim | Produção | Vida dos temporários | `86400` | Start recusado |
|
||||||
|
| ADAPTER_JOB_MODE | Sim, implícito | Produção | `worker` | `worker` | `inline` recusado |
|
||||||
|
| ELO_PRO_LOGIN_URL | Sim | Produção | Login central | `https://app.elo.example/login` | `/login` não encaminha |
|
||||||
|
| ELO_PRO_APP_URL | Recomendada | Produção | `returnTo` no mesmo host | `https://app.elo.example` | Retorno cai no caminho padrão |
|
||||||
|
| AUTH_ALLOWED_RETURN_HOSTS | Não | Produção | Hosts extras do retorno | `app.elo.example` | Host externo continua bloqueado |
|
||||||
|
| ADAPTER_DEV_AUTH | Deve ficar desligada | Produção | Sessão local | `0` | Valor `1` recusa o start |
|
||||||
|
| ADAPTER_DEV_AUTH_SECRET | Não | Só dev | Assina o cookie local | vazio em produção | Não é usado no piloto |
|
||||||
|
| SPREADSHEET_JOB_TIMEOUT_MS | Não | Todos | Teto do processamento | `120000` | Usa 120000 |
|
||||||
|
|
||||||
|
`ADAPTER_DEV_AUTH` não autentica em produção. Storage público não é aceito. TTL inválido falha cedo. Sem Redis, o modo worker não processa.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Runbook de implantação do piloto
|
||||||
|
|
||||||
|
Execute nesta ordem. Não apague auditoria, banco ou arquivos como parte do rollback.
|
||||||
|
|
||||||
|
1. Preparar o projeto Supabase da Elo Pro e anotar URL, anon key e service role.
|
||||||
|
2. Validar que o login Elo Pro emite sessão Supabase e o cookie `elo_pro_workspace_id`.
|
||||||
|
3. Conferir se `profiles`, `workspaces` e `workspace_members` já existem. Se existirem com outro formato, não aplique `0001_platform_shim.sql`. Aplique só `0002`, `0003` e `0004` depois de adaptar `identity-port.ts`.
|
||||||
|
4. Validar RLS no papel `app_user`: um usuário não lê modelo de outro workspace.
|
||||||
|
5. Criar o bucket privado. Não gerar URL pública.
|
||||||
|
6. Subir Redis e preencher `REDIS_URL`.
|
||||||
|
7. Definir `ADAPTER_JOB_MODE=worker`.
|
||||||
|
8. Preencher os secrets da tabela em `15-production-environment.md`. Não definir `ADAPTER_DEV_AUTH` nem o segredo de desenvolvimento.
|
||||||
|
9. Definir `SPREADSHEET_TEMP_TTL_SECONDS` com o prazo operacional do piloto.
|
||||||
|
10. Preencher `SENTRY_DSN`. Confirmar que o evento de teste não contém célula.
|
||||||
|
11. Rodar `npm run migrate` no banco já conferido.
|
||||||
|
12. Iniciar a web com `npm run start`.
|
||||||
|
13. Iniciar `npm run worker` com as mesmas variáveis.
|
||||||
|
14. `GET /api/health/live` deve responder `live`. `GET /api/health/ready` deve responder `ready` sem connection string.
|
||||||
|
15. Executar o smoke do caminho completo no ambiente real (`SMOKE_REAL` ainda não está automatizado contra o host; percorrer a jornada na interface).
|
||||||
|
16. Ativar a flag só no workspace piloto: `npm run flag -- <workspace_id> on`.
|
||||||
|
17. Verificar logs do worker: nome do erro e código, sem planilha.
|
||||||
|
18. Processar um arquivo pequeno de ponta a ponta e baixar XLSX, CSV e PDF.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
1. `npm run flag -- <workspace_id> off`.
|
||||||
|
2. Parar o processo do worker.
|
||||||
|
3. Manter `audit_log`, modelos e o banco.
|
||||||
|
4. Não rodar script que apague storage ou tabelas.
|
||||||
|
5. Se o deploy em si estiver incorreto, promover de volta o artefato anterior depois da flag desligada.
|
||||||
|
|
||||||
|
A última ativação do módulo fica no checklist de `18-pilot-final-gate.md`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Baseline de performance
|
||||||
|
|
||||||
|
Medição local em `tests/performance/baseline.test.ts`, CSV de 200 e de 5.000 linhas, duas colunas, nesta máquina de desenvolvimento. A amostra de 5.000 linhas, somando leitura, CSV e XLSX, ficou abaixo de 1 segundo. Não houve otimização em cima desse número.
|
||||||
|
|
||||||
|
Não foi medida a planilha XLSX de 50.000 linhas no ExcelJS. O limite de 50.000 linhas úteis continua o do domínio, exercitado no caminho CSV em `tests/performance/rows.test.ts`.
|
||||||
|
|
||||||
|
## Limites recomendados para o piloto
|
||||||
|
|
||||||
|
| Limite | Valor |
|
||||||
|
| --- | --- |
|
||||||
|
| Tamanho do arquivo | 10 MB |
|
||||||
|
| Linhas úteis | 50.000 |
|
||||||
|
| Colunas | 200 |
|
||||||
|
| Abas | 30 |
|
||||||
|
| Caracteres por célula | 8.000 |
|
||||||
|
| Fontes por operação | 10 |
|
||||||
|
| Modelos ativos | 10 |
|
||||||
|
| Tempo de processamento | 120 segundos, configurável |
|
||||||
|
| PDF | 200 linhas e 12 colunas, com aviso de truncamento |
|
||||||
|
|
||||||
|
Arquivo vazio, ZIP inválido, macro, CSV fora de UTF-8 e célula acima do teto são recusados antes do processamento. Fórmulas não são executadas. Macros não são abertas.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Gate final do piloto
|
||||||
|
|
||||||
|
**Status: Pilot Ready in Code / Environment Validation Pending**
|
||||||
|
|
||||||
|
Não há novas alterações funcionais até a validação no ambiente real da Elo Pro. O Adaptador só entra no workspace do primeiro cliente depois que todos os itens externos deste gate passarem nesse ambiente.
|
||||||
|
|
||||||
|
| Frente | Estado |
|
||||||
|
| --- | --- |
|
||||||
|
| Código | Aprovado para piloto |
|
||||||
|
| Infraestrutura Elo Pro | Ainda não validada |
|
||||||
|
| Segurança multi-tenant | Aprovada na suíte disponível |
|
||||||
|
| Smoke real | Pendente |
|
||||||
|
| Rollback real | Pendente |
|
||||||
|
| Feature flag do cliente piloto | Desligada |
|
||||||
|
|
||||||
|
Pendências que não bloqueiam o código atual e bloqueiam a ativação: integração com o repositório real da Elo Pro e validação visual com os tokens oficiais. `#0B3D91` permanece provisório e não vira token oficial.
|
||||||
|
|
||||||
|
## Próximo pacote — somente validação no ambiente Elo Pro
|
||||||
|
|
||||||
|
1. Integrar e validar o login real no navegador.
|
||||||
|
2. Validar Postgres, migrations e RLS no banco Elo Pro.
|
||||||
|
3. Provisionar e testar o bucket privado.
|
||||||
|
4. Subir Redis e worker e confirmar processamento assíncrono real.
|
||||||
|
5. Provocar um erro controlado e confirmar sanitização no Sentry.
|
||||||
|
6. Executar o smoke end-to-end completo.
|
||||||
|
7. Executar o ensaio de rollback.
|
||||||
|
8. Integrar os tokens oficiais do Design System quando forem disponibilizados.
|
||||||
|
9. Somente então ativar a feature flag do workspace piloto.
|
||||||
|
|
||||||
|
Verificado nesta máquina em 2 de outubro de 2026:
|
||||||
|
|
||||||
|
- `npx eslint .` passou
|
||||||
|
- `npx tsc --noEmit` passou
|
||||||
|
- `npx vitest run` passou depois da correção do teste de auditoria (34 testes, 1 smoke real ignorado de propósito)
|
||||||
|
- `npx next build` passou
|
||||||
|
|
||||||
|
O smoke com Supabase, Redis e bucket reais não foi executado. `tests/smoke/real-environment.test.ts` permanece ignorado sem `SMOKE_REAL=1`.
|
||||||
|
|
||||||
|
Os prompts 21 e 22 não foram executados: não há repositório da Elo Pro nem tokens oficiais do Design System neste workspace. O azul `#0B3D91` continua provisório.
|
||||||
|
|
||||||
|
## Avaliação
|
||||||
|
|
||||||
|
| Área | Resultado | Teste que ainda falta |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Elo Pro Auth | PASS WITH EXTERNAL DEPENDENCY | Login real no navegador, sessão reconhecida, logout e sessão expirada no host Elo Pro |
|
||||||
|
| Workspace isolation | PASS | Suíte de aplicação executada |
|
||||||
|
| RLS | PASS WITH EXTERNAL DEPENDENCY | Repetir o isolamento no Postgres do piloto, não só no PGlite |
|
||||||
|
| Storage | PASS WITH EXTERNAL DEPENDENCY | Upload e download no bucket privado real |
|
||||||
|
| Redis | PASS WITH EXTERNAL DEPENDENCY | Lock e fila contra um Redis real |
|
||||||
|
| Worker | PASS WITH EXTERNAL DEPENDENCY | Subir `npm run worker` no ambiente do piloto e concluir um job enfileirado |
|
||||||
|
| TTL | PASS | Configuração inválida, arquivo vencido e limpeza repetida cobertos na suíte |
|
||||||
|
| XLSX | PASS | ExcelJS mantido. Fixtures e imutabilidade do modelo executadas |
|
||||||
|
| Observability | PASS WITH EXTERNAL DEPENDENCY | A sanitização foi testada com sentinela. Falta um evento real no projeto Sentry |
|
||||||
|
| Feature flag | PASS | Workspace desligado, flag alterada depois do job e contexto forjado cobertos. A ativação do workspace piloto é o último passo operacional |
|
||||||
|
| Audit trail | PASS | Eventos de upload, mapeamento, processo e download. Update pelo `app_user` é recusado |
|
||||||
|
| Upload security | PASS | Macro, CSV inválido, célula enorme, zip e tamanho cobertos |
|
||||||
|
| Concurrency | PASS | Duas chamadas do mesmo usuário terminam em um resultado |
|
||||||
|
| Production env | PASS WITH EXTERNAL DEPENDENCY | O validador foi testado. Falta subir web e worker com as envs reais do piloto |
|
||||||
|
| Smoke tests | PASS WITH EXTERNAL DEPENDENCY | A jornada local passou. A jornada no ambiente real não foi executada |
|
||||||
|
| Rollback | PASS WITH EXTERNAL DEPENDENCY | O procedimento está no runbook. Um ensaio de rollback não foi feito |
|
||||||
|
|
||||||
|
Nenhum item ficou FAIL no código desta entrega. O piloto não está liberado para o cliente enquanto os itens com dependência externa não forem executados no ambiente real.
|
||||||
|
|
||||||
|
## Congelamento
|
||||||
|
|
||||||
|
| Ocorrência | Classificação |
|
||||||
|
| --- | --- |
|
||||||
|
| `ADAPTER_DEV_AUTH` | Manter apenas em desenvolvimento |
|
||||||
|
| Jobs inline | Manter apenas em desenvolvimento |
|
||||||
|
| Shim `profiles` / `workspaces` / `workspace_members` | Manter até o schema real. Remover a dependência só na integração da Elo Pro |
|
||||||
|
| `console.log` dos scripts de migrate, seed, flag e cleanup | Aceitável no piloto. Não imprimem célula |
|
||||||
|
| `console.error` com o nome do erro | Aceitável no piloto |
|
||||||
|
| Azul `#0B3D91` | Pós-piloto, quando houver token oficial |
|
||||||
|
| Bucket público, senha própria, identidade paralela | Não há. Nada a remover |
|
||||||
|
| TODO / FIXME no código | Não há |
|
||||||
|
|
||||||
|
## Pilot Activation Checklist
|
||||||
|
|
||||||
|
- [ ] Confirmar que o schema Elo Pro já existente não receberá o shim de identidade
|
||||||
|
- [ ] Aplicar as migrations do Adaptador no banco conferido
|
||||||
|
- [ ] Criar o bucket privado
|
||||||
|
- [ ] Configurar Redis, worker, TTL, Sentry e o login central
|
||||||
|
- [ ] Subir web e worker e receber `ready` em `/api/health/ready`
|
||||||
|
- [ ] Executar o smoke real: modelo, fonte, aba, cabeçalho, mapeamento, prévia, processamento, XLSX, CSV e PDF
|
||||||
|
- [ ] Confirmar auditoria sem conteúdo de célula e rollback ensaiado com a flag desligada
|
||||||
|
- [ ] Enable Spreadsheet Adapter for pilot workspace
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
revoke update, delete on table public.audit_log from app_user;
|
||||||
+12
-4
@@ -10,13 +10,21 @@ if (!workspaceId) {
|
|||||||
}
|
}
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
|
||||||
await db.withService((query) =>
|
await db.withService(async (query) => {
|
||||||
query.query(
|
await query.query(
|
||||||
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
`insert into public.workspace_module_flags (workspace_id, module_key, enabled)
|
||||||
values ($1, 'spreadsheet_adapter', $2)
|
values ($1, 'spreadsheet_adapter', $2)
|
||||||
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
|
on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`,
|
||||||
[workspaceId, enabled],
|
[workspaceId, enabled],
|
||||||
),
|
);
|
||||||
);
|
await query.query(
|
||||||
|
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata)
|
||||||
|
select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean)
|
||||||
|
from public.workspace_members m
|
||||||
|
where m.workspace_id = $1 and m.role = 'owner'
|
||||||
|
limit 1`,
|
||||||
|
[workspaceId, enabled],
|
||||||
|
);
|
||||||
|
});
|
||||||
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
|
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
|
||||||
await db.close();
|
await db.close();
|
||||||
+16
-3
@@ -8,6 +8,9 @@ import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, r
|
|||||||
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
|
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
|
||||||
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
|
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
|
||||||
|
|
||||||
|
import { assertProductionEnv } from "../src/config/env";
|
||||||
|
|
||||||
|
assertProductionEnv(process.env);
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl && process.env.NODE_ENV === "production") {
|
if (!databaseUrl && process.env.NODE_ENV === "production") {
|
||||||
throw new Error("DATABASE_URL é obrigatório para o worker.");
|
throw new Error("DATABASE_URL é obrigatório para o worker.");
|
||||||
@@ -17,9 +20,19 @@ await migrate(db);
|
|||||||
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
|
const deps = { db, storage: createStorage(), coordinator: createCoordinator() };
|
||||||
|
|
||||||
async function tick() {
|
async function tick() {
|
||||||
await recoverStaleJobs(deps);
|
await recoverStaleJobs(deps).catch((error) => {
|
||||||
await cleanupExpired(deps);
|
console.error("worker", error instanceof Error ? error.name : "error");
|
||||||
for (const jobId of await listQueuedJobIds(deps)) {
|
});
|
||||||
|
await cleanupExpired(deps).catch((error) => {
|
||||||
|
console.error("worker", "cleanup", error instanceof Error ? error.name : "error");
|
||||||
|
});
|
||||||
|
const queued = new Set(await listQueuedJobIds(deps));
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
const jobId = await deps.coordinator.dequeue().catch(() => null);
|
||||||
|
if (!jobId) break;
|
||||||
|
queued.add(jobId);
|
||||||
|
}
|
||||||
|
for (const jobId of queued) {
|
||||||
const rows = await readJobForWorker(deps, jobId);
|
const rows = await readJobForWorker(deps, jobId);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous") redirect("/login");
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||||
if (!canManageSpreadsheetModels(auth.ctx)) {
|
if (!canManageSpreadsheetModels(auth.ctx)) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function OperationPage() {
|
export default async function OperationPage() {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous") redirect("/login");
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||||
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
|
||||||
return (
|
return (
|
||||||
<AppShell ctx={auth.ctx}>
|
<AppShell ctx={auth.ctx}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function AdapterHome() {
|
export default async function AdapterHome() {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous") redirect("/login");
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login");
|
||||||
if (auth.kind !== "ok") {
|
if (auth.kind !== "ok") {
|
||||||
return (
|
return (
|
||||||
<main className="p-8">
|
<main className="p-8">
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ status: "live", module: "spreadsheet-adapter" });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { collectProductionIssues } from "@/config/env";
|
||||||
|
import { getDb } from "@/infra/db/client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const checks: Record<string, "ok" | "missing" | "down"> = {
|
||||||
|
database: "down",
|
||||||
|
redis: process.env.REDIS_URL ? "ok" : "missing",
|
||||||
|
storage: process.env.SUPABASE_STORAGE_BUCKET && process.env.SUPABASE_STORAGE_BUCKET !== "public" ? "ok" : "missing",
|
||||||
|
config: collectProductionIssues(process.env).length === 0 ? "ok" : "missing",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
await db.withService((query) => query.query("select 1 as ok"));
|
||||||
|
checks.database = "ok";
|
||||||
|
} catch {
|
||||||
|
checks.database = "down";
|
||||||
|
}
|
||||||
|
const ready = checks.database === "ok" && (process.env.NODE_ENV !== "production" || (checks.redis === "ok" && checks.storage === "ok" && checks.config === "ok"));
|
||||||
|
return NextResponse.json({ status: ready ? "ready" : "not_ready", checks }, { status: ready ? 200 : 503 });
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { presentError } from "@/domain/spreadsheet-adapter/error-catalog";
|
||||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||||
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
|
||||||
import { resolveAuth } from "@/server/auth/context";
|
import { resolveAuth } from "@/server/auth/context";
|
||||||
@@ -9,18 +10,25 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export function errorResponse(error: unknown): NextResponse {
|
export function errorResponse(error: unknown): NextResponse {
|
||||||
if (isAdapterError(error)) {
|
if (isAdapterError(error)) {
|
||||||
|
const view = presentError(error.code);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: { code: error.code, message: error.message, details: error.details ?? null } },
|
{ error: { code: error.code, message: error.message, action: view.action, category: view.category, reference: error.code, details: error.details ?? null } },
|
||||||
{ status: error.status },
|
{ status: error.status },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.error("spreadsheet-adapter", error instanceof Error ? error.name : "error");
|
console.error("spreadsheet-adapter", error instanceof Error ? error.name : "error");
|
||||||
return NextResponse.json({ error: { code: "INTERNAL", message: "Erro interno." } }, { status: 500 });
|
const view = presentError("INTERNAL");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: "INTERNAL", message: "Erro interno.", action: view.action, category: view.category, reference: "INTERNAL" } },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireReady(): Promise<{ ctx: RequestContext; deps: AppDeps }> {
|
export async function requireReady(): Promise<{ ctx: RequestContext; deps: AppDeps }> {
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "anonymous") throw new AdapterError("UNAUTHENTICATED", "Sessão Elo Pro necessária.", 401);
|
if (auth.kind === "anonymous" || auth.kind === "session_expired") {
|
||||||
|
throw new AdapterError("UNAUTHENTICATED", auth.kind === "session_expired" ? "Sessão expirada. Entre novamente." : "Sessão Elo Pro necessária.", 401);
|
||||||
|
}
|
||||||
if (auth.kind !== "ok") throw new AdapterError("WORKSPACE_FORBIDDEN", "Workspace não autorizado.", 403);
|
if (auth.kind !== "ok") throw new AdapterError("WORKSPACE_FORBIDDEN", "Workspace não autorizado.", 403);
|
||||||
const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime");
|
const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime");
|
||||||
return { ctx: auth.ctx, deps: await runtimeDeps() };
|
return { ctx: auth.ctx, deps: await runtimeDeps() };
|
||||||
|
|||||||
+10
-3
@@ -2,14 +2,20 @@ import { redirect } from "next/navigation";
|
|||||||
import { DEV_IDENTITIES } from "@/dev/fixtures";
|
import { DEV_IDENTITIES } from "@/dev/fixtures";
|
||||||
import { resolveAuth } from "@/server/auth/context";
|
import { resolveAuth } from "@/server/auth/context";
|
||||||
import { devAuthEnabled } from "@/server/auth/dev-session";
|
import { devAuthEnabled } from "@/server/auth/dev-session";
|
||||||
|
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
||||||
import { DevLogin } from "@/components/dev-login";
|
import { DevLogin } from "@/components/dev-login";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage({ searchParams }: { searchParams: Promise<{ next?: string; reason?: string }> }) {
|
||||||
|
const params = await searchParams;
|
||||||
const auth = await resolveAuth();
|
const auth = await resolveAuth();
|
||||||
if (auth.kind === "ok") redirect("/adaptador");
|
const nextPath = safeReturnPath(params.next);
|
||||||
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) redirect(process.env.ELO_PRO_LOGIN_URL);
|
if (auth.kind === "ok") redirect(nextPath);
|
||||||
|
if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) {
|
||||||
|
redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL));
|
||||||
|
}
|
||||||
|
const expired = auth.kind === "session_expired" || params.reason === "expired";
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-xl p-8">
|
<main className="mx-auto max-w-xl p-8">
|
||||||
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
|
||||||
@@ -17,6 +23,7 @@ export default async function LoginPage() {
|
|||||||
<p className="mt-3 text-sm text-ink/80">
|
<p className="mt-3 text-sm text-ink/80">
|
||||||
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
|
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
|
||||||
</p>
|
</p>
|
||||||
|
{expired ? <p className="mt-4 text-sm">A sessão expirou. Entre novamente pelo Elo Pro.</p> : null}
|
||||||
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
|
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<{ text: string; action?: string; reference?: string } | null>(null);
|
||||||
const [criteria, setCriteria] = useState(false);
|
const [criteria, setCriteria] = useState(false);
|
||||||
const [sheetDialog, setSheetDialog] = useState<{ sourceId: string; sheets: string[] } | null>(null);
|
const [sheetDialog, setSheetDialog] = useState<{ sourceId: string; sheets: string[] } | null>(null);
|
||||||
const [mappings, setMappings] = useState<{ sourceColumn: string; modelColumn: string }[]>([]);
|
const [mappings, setMappings] = useState<{ sourceColumn: string; modelColumn: string }[]>([]);
|
||||||
@@ -94,6 +94,10 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fail(error?: { message?: string; action?: string; code?: string }, fallback = "Não foi possível continuar.") {
|
||||||
|
setMessage({ text: error?.message ?? fallback, action: error?.action, reference: error?.code });
|
||||||
|
}
|
||||||
|
|
||||||
async function start(modelId: string) {
|
async function start(modelId: string) {
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
const response = await fetch("/api/spreadsheet-adapter/jobs", {
|
const response = await fetch("/api/spreadsheet-adapter/jobs", {
|
||||||
@@ -101,9 +105,9 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }),
|
body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }),
|
||||||
});
|
});
|
||||||
const data = await readJson<{ job?: JobDto; error?: { message: string } }>(response);
|
const data = await readJson<{ job?: JobDto; error?: { message: string; action?: string; code?: string } }>(response);
|
||||||
if (!response.ok || !data.job) {
|
if (!response.ok || !data.job) {
|
||||||
setMessage(data.error?.message ?? "Não foi possível abrir a operação");
|
fail(data.error, "Não foi possível abrir a operação");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setJobId(data.job.id);
|
setJobId(data.job.id);
|
||||||
@@ -117,12 +121,12 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
form.set("file", file);
|
form.set("file", file);
|
||||||
form.set("jobId", jobId);
|
form.set("jobId", jobId);
|
||||||
const response = await fetch("/api/spreadsheet-adapter/sources/validate", { method: "POST", body: form });
|
const response = await fetch("/api/spreadsheet-adapter/sources/validate", { method: "POST", body: form });
|
||||||
const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string } }>(response);
|
const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string; action?: string; code?: string } }>(response);
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setMessage(data.error.message);
|
fail(data.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMessage(data.message ?? null);
|
setMessage(data.message ? { text: data.message } : null);
|
||||||
const created = data.job.sources.at(-1);
|
const created = data.job.sources.at(-1);
|
||||||
if (created && data.code === "SHEET_REQUIRED") setSheetDialog({ sourceId: created.id, sheets: created.sheets });
|
if (created && data.code === "SHEET_REQUIRED") setSheetDialog({ sourceId: created.id, sheets: created.sheets });
|
||||||
await job.refetch();
|
await job.refetch();
|
||||||
@@ -147,7 +151,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
});
|
});
|
||||||
const data = await readJson<PreviewContract & { error?: { message: string } }>(response);
|
const data = await readJson<PreviewContract & { error?: { message: string } }>(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setMessage(data.error?.message ?? "Prévia indisponível");
|
fail(data.error, "Prévia indisponível");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPreview(data);
|
setPreview(data);
|
||||||
@@ -163,8 +167,8 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sourceId: source.id, idempotencyKey: crypto.randomUUID() }),
|
body: JSON.stringify({ sourceId: source.id, idempotencyKey: crypto.randomUUID() }),
|
||||||
});
|
});
|
||||||
const data = await readJson<{ error?: { message: string } }>(response);
|
const data = await readJson<{ error?: { message: string; action?: string; code?: string } }>(response);
|
||||||
if (!response.ok) setMessage(data.error?.message ?? "Processamento não iniciado");
|
if (!response.ok) fail(data.error, "Processamento não iniciado");
|
||||||
await job.refetch();
|
await job.refetch();
|
||||||
setStep(5);
|
setStep(5);
|
||||||
}
|
}
|
||||||
@@ -176,7 +180,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
<p className="mt-2 text-sm text-ink/75">Uma transferência por vez. A prévia é obrigatória antes de gerar o arquivo.</p>
|
<p className="mt-2 text-sm text-ink/75">Uma transferência por vez. A prévia é obrigatória antes de gerar o arquivo.</p>
|
||||||
</header>
|
</header>
|
||||||
<JourneyTrail steps={["Modelo", "Fontes", "Mapeamento", "Revisão", "Processamento", "Resultado"]} current={step} />
|
<JourneyTrail steps={["Modelo", "Fontes", "Mapeamento", "Revisão", "Processamento", "Resultado"]} current={step} />
|
||||||
{message ? <ErrorState message={message} /> : null}
|
{message ? <ErrorState message={message.text} action={message.action} reference={message.reference} /> : null}
|
||||||
{step === 0 ? (
|
{step === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
{models.isLoading ? <LoadingBlock /> : null}
|
{models.isLoading ? <LoadingBlock /> : null}
|
||||||
@@ -284,7 +288,9 @@ export function Operation({ ctx }: { ctx: RequestContext }) {
|
|||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-lg font-semibold">{job.data.progress?.label ?? "Processando"}</h2>
|
<h2 className="text-lg font-semibold">{job.data.progress?.label ?? "Processando"}</h2>
|
||||||
<p className="mt-2 text-sm">{job.data.progress?.percent == null ? "Progresso ainda não é estimável." : `${job.data.progress.percent}%`}</p>
|
<p className="mt-2 text-sm">{job.data.progress?.percent == null ? "Progresso ainda não é estimável." : `${job.data.progress.percent}%`}</p>
|
||||||
{job.data.status === "failed" ? <ErrorState message={job.data.errorCode ?? "Falha no processamento"} onRetry={() => void processCurrent()} /> : null}
|
{job.data.status === "failed" ? (
|
||||||
|
<ErrorState message="O processamento não foi concluído." action="Tente novamente." reference={job.data.errorCode ?? undefined} onRetry={() => void processCurrent()} />
|
||||||
|
) : null}
|
||||||
{source?.hasOutput ? (
|
{source?.hasOutput ? (
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{(["xlsx", "csv", "pdf"] as const).map((format) => (
|
{(["xlsx", "csv", "pdf"] as const).map((format) => (
|
||||||
|
|||||||
@@ -19,11 +19,23 @@ export function EmptyState({ title, description, action }: { title: string; desc
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
export function ErrorState({
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
reference,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
action?: string;
|
||||||
|
reference?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="rounded-[14px] border border-danger bg-white p-4">
|
<div role="alert" className="rounded-[14px] border border-danger bg-white p-4">
|
||||||
<p className="font-semibold text-danger">Não foi possível continuar</p>
|
<p className="font-semibold text-danger">Não foi possível continuar</p>
|
||||||
<p className="mt-1 text-sm">{message}</p>
|
<p className="mt-1 text-sm">{message}</p>
|
||||||
|
{action ? <p className="mt-1 text-sm">{action}</p> : null}
|
||||||
|
{reference ? <p className="mt-2 text-xs">Referência: {reference}</p> : null}
|
||||||
{onRetry ? (
|
{onRetry ? (
|
||||||
<button type="button" className="mt-3 text-sm font-semibold underline" onClick={onRetry}>
|
<button type="button" className="mt-3 text-sm font-semibold underline" onClick={onRetry}>
|
||||||
Tentar novamente
|
Tentar novamente
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export type EnvMap = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export type EnvIssue = { variable: string; message: string };
|
||||||
|
|
||||||
|
const REQUIRED_IN_PRODUCTION = [
|
||||||
|
"NEXT_PUBLIC_SUPABASE_URL",
|
||||||
|
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||||
|
"SUPABASE_SERVICE_ROLE_KEY",
|
||||||
|
"SUPABASE_STORAGE_BUCKET",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"REDIS_URL",
|
||||||
|
"SENTRY_DSN",
|
||||||
|
"ELO_PRO_LOGIN_URL",
|
||||||
|
"SPREADSHEET_TEMP_TTL_SECONDS",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function collectProductionIssues(env: EnvMap): EnvIssue[] {
|
||||||
|
if (env.NODE_ENV !== "production") return [];
|
||||||
|
const issues: EnvIssue[] = [];
|
||||||
|
for (const variable of REQUIRED_IN_PRODUCTION) {
|
||||||
|
if (!env[variable] || env[variable]?.trim() === "") {
|
||||||
|
issues.push({ variable, message: "obrigatória em produção" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (env.ADAPTER_DEV_AUTH === "1") {
|
||||||
|
issues.push({ variable: "ADAPTER_DEV_AUTH", message: "não pode estar ligado em produção" });
|
||||||
|
}
|
||||||
|
if (env.ADAPTER_JOB_MODE === "inline") {
|
||||||
|
issues.push({ variable: "ADAPTER_JOB_MODE", message: "inline não é permitido em produção" });
|
||||||
|
}
|
||||||
|
const ttl = env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||||
|
if (ttl && ttl.trim() !== "") {
|
||||||
|
const value = Number(ttl);
|
||||||
|
if (!Number.isInteger(value) || value <= 0 || value > 60 * 60 * 24 * 7) {
|
||||||
|
issues.push({ variable: "SPREADSHEET_TEMP_TTL_SECONDS", message: "inteiro entre 1 e 604800" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (env.SUPABASE_STORAGE_BUCKET === "public") {
|
||||||
|
issues.push({ variable: "SUPABASE_STORAGE_BUCKET", message: "bucket público não é aceito" });
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProductionEnv(env: EnvMap = process.env): void {
|
||||||
|
if (env.NODE_ENV !== "production") return;
|
||||||
|
if (env.NEXT_PHASE === "phase-production-build") return;
|
||||||
|
if (env.npm_lifecycle_event === "build") return;
|
||||||
|
const issues = collectProductionIssues(env);
|
||||||
|
if (issues.length === 0) return;
|
||||||
|
const summary = issues.map((issue) => `${issue.variable}: ${issue.message}`).join("; ");
|
||||||
|
throw new Error(`Configuração de produção recusada. ${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobRunsInline(env: EnvMap = process.env): boolean {
|
||||||
|
if (env.NODE_ENV === "production" && env.ADAPTER_JOB_MODE === "inline") return false;
|
||||||
|
if (env.ADAPTER_JOB_MODE === "worker") return false;
|
||||||
|
if (env.ADAPTER_JOB_MODE === "inline") return true;
|
||||||
|
return env.NODE_ENV !== "production";
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { AdapterError } from "./errors";
|
import { AdapterError } from "./errors";
|
||||||
import { extractHeaders } from "./headers";
|
import { extractHeaders } from "./headers";
|
||||||
import { MAX_USEFUL_ROWS } from "./limits";
|
import { MAX_CELL_CHARS, MAX_COLUMNS, MAX_USEFUL_ROWS } from "./limits";
|
||||||
import { isUsefulRow } from "./transform";
|
import { isUsefulRow } from "./transform";
|
||||||
import { inferColumns, typeConflictMessages } from "./typing";
|
import { inferColumns, typeConflictMessages } from "./typing";
|
||||||
import type { ParsedGrid } from "./types";
|
import type { ParsedGrid } from "./types";
|
||||||
@@ -68,9 +68,17 @@ export function gridFromRows(allRows: string[][], headerRow: number, sheetNames:
|
|||||||
throw new AdapterError("HEADER_ROW_INVALID", "A linha de cabeçalho está além do conteúdo do arquivo.");
|
throw new AdapterError("HEADER_ROW_INVALID", "A linha de cabeçalho está além do conteúdo do arquivo.");
|
||||||
}
|
}
|
||||||
const headers = extractHeaders(allRows[headerRow - 1] ?? []);
|
const headers = extractHeaders(allRows[headerRow - 1] ?? []);
|
||||||
|
if (headers.length > MAX_COLUMNS) {
|
||||||
|
throw new AdapterError("TOO_MANY_COLUMNS", `O arquivo excede ${MAX_COLUMNS} colunas.`);
|
||||||
|
}
|
||||||
const data: string[][] = [];
|
const data: string[][] = [];
|
||||||
for (const raw of allRows.slice(headerRow)) {
|
for (const raw of allRows.slice(headerRow)) {
|
||||||
const sized = headers.map((_, index) => String(raw[index] ?? ""));
|
const sized = headers.map((_, index) => String(raw[index] ?? ""));
|
||||||
|
for (const value of sized) {
|
||||||
|
if (value.length > MAX_CELL_CHARS) {
|
||||||
|
throw new AdapterError("CELL_TOO_LARGE", "Uma célula excede o tamanho permitido.");
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!isUsefulRow(sized)) continue;
|
if (!isUsefulRow(sized)) continue;
|
||||||
data.push(sized);
|
data.push(sized);
|
||||||
if (data.length > MAX_USEFUL_ROWS) {
|
if (data.length > MAX_USEFUL_ROWS) {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export type ErrorCategory =
|
||||||
|
| "authentication"
|
||||||
|
| "authorization"
|
||||||
|
| "invalid_file"
|
||||||
|
| "file_too_large"
|
||||||
|
| "invalid_configuration"
|
||||||
|
| "temporary"
|
||||||
|
| "processing"
|
||||||
|
| "timeout"
|
||||||
|
| "storage"
|
||||||
|
| "worker";
|
||||||
|
|
||||||
|
export type PublicErrorView = {
|
||||||
|
category: ErrorCategory;
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VIEWS: Record<string, PublicErrorView> = {
|
||||||
|
UNAUTHENTICATED: { category: "authentication", action: "Entre novamente pelo Elo Pro." },
|
||||||
|
WORKSPACE_FORBIDDEN: { category: "authorization", action: "Peça acesso ao workspace ou selecione outro no Elo Pro." },
|
||||||
|
FEATURE_DISABLED: { category: "authorization", action: "O módulo não está ativo neste workspace." },
|
||||||
|
FORBIDDEN: { category: "authorization", action: "Sua função não permite esta ação." },
|
||||||
|
FILE_TOO_LARGE: { category: "file_too_large", action: "Envie um arquivo de até 10 MB." },
|
||||||
|
TOO_MANY_ROWS: { category: "file_too_large", action: "Divida a planilha e envie até 50.000 linhas úteis." },
|
||||||
|
TOO_MANY_COLUMNS: { category: "file_too_large", action: "Reduza a quantidade de colunas." },
|
||||||
|
CELL_TOO_LARGE: { category: "file_too_large", action: "Reduza o texto da célula maior e envie de novo." },
|
||||||
|
EMPTY_FILE: { category: "invalid_file", action: "Envie um arquivo com conteúdo." },
|
||||||
|
UNSUPPORTED_FORMAT: { category: "invalid_file", action: "Use XLSX ou CSV." },
|
||||||
|
INVALID_STRUCTURE: { category: "invalid_file", action: "Verifique se o arquivo abre no editor e não está protegido por senha." },
|
||||||
|
INVALID_FORMAT: { category: "invalid_file", action: "Use XLSX ou CSV." },
|
||||||
|
MIME_MISMATCH: { category: "invalid_file", action: "O conteúdo do arquivo não corresponde à extensão." },
|
||||||
|
CSV_INVALID: { category: "invalid_file", action: "Salve o CSV em UTF-8 e confira aspas e delimitador." },
|
||||||
|
ZIP_BOMB: { category: "invalid_file", action: "O arquivo compactado foi recusado." },
|
||||||
|
MACROS_NOT_ALLOWED: { category: "invalid_file", action: "Remova as macros e envie somente a planilha." },
|
||||||
|
TIMEOUT: { category: "timeout", action: "Tente de novo com um arquivo menor." },
|
||||||
|
REDIS_UNAVAILABLE: { category: "worker", action: "A fila está indisponível. Aguarde e tente novamente." },
|
||||||
|
JOB_LOCKED: { category: "processing", action: "Aguarde a transferência em andamento terminar." },
|
||||||
|
STORAGE_FAILURE: { category: "storage", action: "Não foi possível guardar o arquivo. Tente novamente." },
|
||||||
|
INVALID_SETTING: { category: "invalid_configuration", action: "Revise a configuração com um administrador." },
|
||||||
|
PROCESS_FAILED: { category: "processing", action: "Tente processar novamente." },
|
||||||
|
INTERNAL: { category: "temporary", action: "Tente novamente. Se continuar, informe a referência ao suporte." },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function presentError(code: string): PublicErrorView {
|
||||||
|
return VIEWS[code] ?? { category: "processing", action: "Tente novamente. Se continuar, informe a referência ao suporte." };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MAX_PROCESS_ATTEMPTS } from "./limits";
|
||||||
|
|
||||||
|
const NO_RETRY = new Set([
|
||||||
|
"TIMEOUT",
|
||||||
|
"TYPE_INCONSISTENT",
|
||||||
|
"MAPPING_INCOMPLETE",
|
||||||
|
"MAPPING_CONFLICT",
|
||||||
|
"MAPPING_UNKNOWN",
|
||||||
|
"FORBIDDEN",
|
||||||
|
"FEATURE_DISABLED",
|
||||||
|
"JOB_LOCKED",
|
||||||
|
"NOT_FOUND",
|
||||||
|
"MODEL_TAMPERED",
|
||||||
|
"REDIS_UNAVAILABLE",
|
||||||
|
"FILE_TOO_LARGE",
|
||||||
|
"TOO_MANY_ROWS",
|
||||||
|
"TOO_MANY_COLUMNS",
|
||||||
|
"CELL_TOO_LARGE",
|
||||||
|
"MACROS_NOT_ALLOWED",
|
||||||
|
"INVALID_STRUCTURE",
|
||||||
|
"ZIP_BOMB",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function shouldRequeue(code: string, httpStatus: number, attempt: number): boolean {
|
||||||
|
if (NO_RETRY.has(code)) return false;
|
||||||
|
if (httpStatus < 500) return false;
|
||||||
|
return attempt < MAX_PROCESS_ATTEMPTS;
|
||||||
|
}
|
||||||
@@ -55,7 +55,14 @@ export function assertZipSafe(buf: Buffer): void {
|
|||||||
if (rawSize > 0 && compressed > 0 && rawSize / compressed > 100 && rawSize > 10_000_000) {
|
if (rawSize > 0 && compressed > 0 && rawSize / compressed > 100 && rawSize > 10_000_000) {
|
||||||
throw new AdapterError("ZIP_BOMB", "Taxa de compressão anômala.");
|
throw new AdapterError("ZIP_BOMB", "Taxa de compressão anômala.");
|
||||||
}
|
}
|
||||||
const dataStart = offset + 30 + nameLen + extraLen;
|
const nameStart = offset + 30;
|
||||||
|
const nameEnd = nameStart + nameLen;
|
||||||
|
if (nameEnd > buf.length) throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
|
||||||
|
const entryName = buf.toString("utf8", nameStart, nameEnd);
|
||||||
|
if (/vbaProject\.bin|xl\/macrosheets/i.test(entryName)) {
|
||||||
|
throw new AdapterError("MACROS_NOT_ALLOWED", "O arquivo contém macros. O Adaptador não as executa.");
|
||||||
|
}
|
||||||
|
const dataStart = nameEnd + extraLen;
|
||||||
if (flags & 0x08 && compressed === 0) break;
|
if (flags & 0x08 && compressed === 0) break;
|
||||||
if (dataStart + compressed > buf.length + 64) {
|
if (dataStart + compressed > buf.length + 64) {
|
||||||
throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
|
throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export const MAX_USEFUL_ROWS = 50_000;
|
|||||||
export const PREVIEW_ROWS = 8;
|
export const PREVIEW_ROWS = 8;
|
||||||
export const PDF_MAX_ROWS = 200;
|
export const PDF_MAX_ROWS = 200;
|
||||||
export const PDF_MAX_COLUMNS = 12;
|
export const PDF_MAX_COLUMNS = 12;
|
||||||
|
export const MAX_SHEETS = 30;
|
||||||
|
export const MAX_COLUMNS = 200;
|
||||||
|
export const MAX_CELL_CHARS = 8_000;
|
||||||
|
export const MAX_PROCESS_ATTEMPTS = 3;
|
||||||
|
|
||||||
export function assertFileSize(size: number): void {
|
export function assertFileSize(size: number): void {
|
||||||
if (size <= 0) {
|
if (size <= 0) {
|
||||||
|
|||||||
@@ -21,3 +21,13 @@ export function canUploadSpreadsheetModel(
|
|||||||
export function canOperateSpreadsheet(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
|
export function canOperateSpreadsheet(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
|
||||||
return canUseSpreadsheetAdapter(ctx);
|
return canUseSpreadsheetAdapter(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adapterPermissions(
|
||||||
|
ctx: Pick<RequestContext, "role" | "moduleEnabled" | "allowNonAdminModelUpload">,
|
||||||
|
): string[] {
|
||||||
|
const permissions = ["adapter.session"];
|
||||||
|
if (canUseSpreadsheetAdapter(ctx)) permissions.push("adapter.use");
|
||||||
|
if (canManageSpreadsheetModels(ctx)) permissions.push("adapter.manage");
|
||||||
|
if (canUploadSpreadsheetModel(ctx)) permissions.push("adapter.upload");
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Política técnica provisória. O prazo numérico de produto continua em
|
||||||
|
* SPREADSHEET_TEMP_TTL_SECONDS e ainda depende de aprovação operacional.
|
||||||
|
*/
|
||||||
|
export const retentionPolicy = {
|
||||||
|
mapping: "kept_with_job",
|
||||||
|
model: "soft_delete",
|
||||||
|
sourceFile: "hard_delete_after_ttl",
|
||||||
|
resultFile: "hard_delete_after_ttl",
|
||||||
|
job: "metadata_kept",
|
||||||
|
audit: "append_only",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RetentionClass = keyof typeof retentionPolicy;
|
||||||
@@ -59,6 +59,7 @@ export type JobProgress = {
|
|||||||
sourceIndex: number;
|
sourceIndex: number;
|
||||||
sourceCount: number;
|
sourceCount: number;
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
|
attempt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestContext = {
|
export type RequestContext = {
|
||||||
@@ -70,4 +71,10 @@ export type RequestContext = {
|
|||||||
role: Role;
|
role: Role;
|
||||||
moduleEnabled: boolean;
|
moduleEnabled: boolean;
|
||||||
allowNonAdminModelUpload: boolean;
|
allowNonAdminModelUpload: boolean;
|
||||||
|
permissions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Contexto autenticado resolvido no servidor. O workspace não vem do cliente. */
|
||||||
|
export type AuthenticatedContext = RequestContext & {
|
||||||
|
permissions: string[];
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,15 @@
|
|||||||
export { scrubSentryEvent } from "./scrub";
|
import { scrubSentryEvent, sentrySafeError } from "./scrub";
|
||||||
|
|
||||||
|
export { scrubSentryEvent, sentrySafeError };
|
||||||
|
|
||||||
|
export function buildSentryPayload(error: unknown, tags: Record<string, string>) {
|
||||||
|
const safe = sentrySafeError(error);
|
||||||
|
return scrubSentryEvent({
|
||||||
|
message: safe.message,
|
||||||
|
extra: tags,
|
||||||
|
exception: { values: [{ value: error instanceof Error ? error.message : "UNEXPECTED" }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function captureAdapterException(error: unknown, tags: Record<string, string>): void {
|
export function captureAdapterException(error: unknown, tags: Record<string, string>): void {
|
||||||
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
if (!process.env.SENTRY_DSN && !process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||||
@@ -6,7 +17,7 @@ export function captureAdapterException(error: unknown, tags: Record<string, str
|
|||||||
Sentry.withScope((scope) => {
|
Sentry.withScope((scope) => {
|
||||||
scope.setTag("module", "spreadsheet-adapter");
|
scope.setTag("module", "spreadsheet-adapter");
|
||||||
for (const [key, value] of Object.entries(tags)) scope.setTag(key, value.slice(0, 120));
|
for (const [key, value] of Object.entries(tags)) scope.setTag(key, value.slice(0, 120));
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(sentrySafeError(error));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
const BLOCKED_KEY = /^(cells|rows|row_values|values|content|token|password|secret|authorization|cookie|file_base64|spreadsheet|csv_body|xlsx_body)$/i;
|
const BLOCKED_KEY = /^(cells|rows|row_values|values|content|token|password|secret|authorization|cookie|file_base64|spreadsheet|csv_body|xlsx_body|preview)$/i;
|
||||||
|
const SAFE_TEXT = /^[A-Za-z0-9_.:-]{1,80}$/;
|
||||||
|
|
||||||
|
function redactFreeText(value: string): string {
|
||||||
|
return SAFE_TEXT.test(value) ? value : "redacted";
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeAuditMetadata(input: Record<string, unknown> | undefined): Record<string, string | number | boolean | null> {
|
export function sanitizeAuditMetadata(input: Record<string, unknown> | undefined): Record<string, string | number | boolean | null> {
|
||||||
const output: Record<string, string | number | boolean | null> = {};
|
const output: Record<string, string | number | boolean | null> = {};
|
||||||
@@ -11,7 +16,7 @@ export function sanitizeAuditMetadata(input: Record<string, unknown> | undefined
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies?: unknown; headers?: Record<string, string> }; extra?: Record<string, unknown>; breadcrumbs?: unknown[] }>(
|
export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies?: unknown; headers?: Record<string, string> }; extra?: Record<string, unknown>; breadcrumbs?: unknown[]; exception?: { values?: Array<{ value?: string }> }; message?: string }>(
|
||||||
event: T,
|
event: T,
|
||||||
): T {
|
): T {
|
||||||
if (event.request) {
|
if (event.request) {
|
||||||
@@ -27,9 +32,28 @@ export function scrubSentryEvent<T extends { request?: { data?: unknown; cookies
|
|||||||
}
|
}
|
||||||
if (event.extra) {
|
if (event.extra) {
|
||||||
for (const key of Object.keys(event.extra)) {
|
for (const key of Object.keys(event.extra)) {
|
||||||
if (BLOCKED_KEY.test(key)) delete event.extra[key];
|
if (BLOCKED_KEY.test(key)) {
|
||||||
|
delete event.extra[key];
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
const value = event.extra[key];
|
||||||
|
if (typeof value === "string") event.extra[key] = redactFreeText(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.message) event.message = redactFreeText(event.message);
|
||||||
|
for (const item of event.exception?.values ?? []) {
|
||||||
|
if (item.value) item.value = redactFreeText(item.value);
|
||||||
}
|
}
|
||||||
event.breadcrumbs = [];
|
event.breadcrumbs = [];
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sentrySafeError(error: unknown): Error {
|
||||||
|
const code =
|
||||||
|
error && typeof error === "object" && "code" in error && typeof (error as { code: unknown }).code === "string"
|
||||||
|
? (error as { code: string }).code
|
||||||
|
: "UNEXPECTED";
|
||||||
|
const safe = new Error(SAFE_TEXT.test(code) ? code : "UNEXPECTED");
|
||||||
|
safe.name = "AdapterFailure";
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ export interface Coordinator {
|
|||||||
releaseLock(key: string, owner: string): Promise<void>;
|
releaseLock(key: string, owner: string): Promise<void>;
|
||||||
setProgress(jobId: string, progress: JobProgress): Promise<void>;
|
setProgress(jobId: string, progress: JobProgress): Promise<void>;
|
||||||
getProgress(jobId: string): Promise<JobProgress | null>;
|
getProgress(jobId: string): Promise<JobProgress | null>;
|
||||||
|
enqueue(jobId: string): Promise<void>;
|
||||||
|
dequeue(): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LockRecord = { owner: string; expiresAt: number };
|
type LockRecord = { owner: string; expiresAt: number };
|
||||||
@@ -15,6 +17,7 @@ type LockRecord = { owner: string; expiresAt: number };
|
|||||||
export class MemoryCoordinator implements Coordinator {
|
export class MemoryCoordinator implements Coordinator {
|
||||||
private readonly locks = new Map<string, LockRecord>();
|
private readonly locks = new Map<string, LockRecord>();
|
||||||
private readonly progress = new Map<string, JobProgress>();
|
private readonly progress = new Map<string, JobProgress>();
|
||||||
|
private readonly queue: string[] = [];
|
||||||
|
|
||||||
constructor(private readonly now: () => number = () => Date.now()) {}
|
constructor(private readonly now: () => number = () => Date.now()) {}
|
||||||
|
|
||||||
@@ -58,6 +61,14 @@ export class MemoryCoordinator implements Coordinator {
|
|||||||
async getProgress(jobId: string): Promise<JobProgress | null> {
|
async getProgress(jobId: string): Promise<JobProgress | null> {
|
||||||
return this.progress.get(jobId) ?? null;
|
return this.progress.get(jobId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enqueue(jobId: string): Promise<void> {
|
||||||
|
if (!this.queue.includes(jobId)) this.queue.push(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dequeue(): Promise<string | null> {
|
||||||
|
return this.queue.shift() ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnavailableCoordinator implements Coordinator {
|
export class UnavailableCoordinator implements Coordinator {
|
||||||
@@ -83,4 +94,10 @@ export class UnavailableCoordinator implements Coordinator {
|
|||||||
getProgress(): Promise<JobProgress | null> {
|
getProgress(): Promise<JobProgress | null> {
|
||||||
this.fail();
|
this.fail();
|
||||||
}
|
}
|
||||||
|
enqueue(): Promise<void> {
|
||||||
|
this.fail();
|
||||||
|
}
|
||||||
|
dequeue(): Promise<string | null> {
|
||||||
|
this.fail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,16 @@ export class RedisCoordinator implements Coordinator {
|
|||||||
return raw ? (JSON.parse(raw) as JobProgress) : null;
|
return raw ? (JSON.parse(raw) as JobProgress) : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enqueue(jobId: string): Promise<void> {
|
||||||
|
await this.run(async () => {
|
||||||
|
await this.redis.lpush(queueKey(), jobId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dequeue(): Promise<string | null> {
|
||||||
|
return this.run(async () => this.redis.rpop(queueKey()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function envNamespace(): string {
|
export function envNamespace(): string {
|
||||||
@@ -72,3 +82,7 @@ export function lockKey(userId: string): string {
|
|||||||
export function progressKey(jobId: string): string {
|
export function progressKey(jobId: string): string {
|
||||||
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`;
|
return `spreadsheet-adapter:${envNamespace()}:progress:${jobId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function queueKey(): string {
|
||||||
|
return `spreadsheet-adapter:${envNamespace()}:queue`;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import ExcelJS from "exceljs";
|
|||||||
import { gridFromRows, parseCsvText } from "@/domain/spreadsheet-adapter/csv";
|
import { gridFromRows, parseCsvText } from "@/domain/spreadsheet-adapter/csv";
|
||||||
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||||
import { assertZipSafe, detectSpreadsheetFormat } from "@/domain/spreadsheet-adapter/file-signature";
|
import { assertZipSafe, detectSpreadsheetFormat } from "@/domain/spreadsheet-adapter/file-signature";
|
||||||
import { MAX_USEFUL_ROWS } from "@/domain/spreadsheet-adapter/limits";
|
import { MAX_CELL_CHARS, MAX_COLUMNS, MAX_SHEETS, MAX_USEFUL_ROWS } from "@/domain/spreadsheet-adapter/limits";
|
||||||
import type { ParsedGrid, SpreadsheetFormat } from "@/domain/spreadsheet-adapter/types";
|
import type { ParsedGrid, SpreadsheetFormat } from "@/domain/spreadsheet-adapter/types";
|
||||||
|
|
||||||
export async function readSpreadsheet(
|
export async function readSpreadsheet(
|
||||||
@@ -13,6 +13,11 @@ export async function readSpreadsheet(
|
|||||||
const format = detectSpreadsheetFormat(filename, bytes);
|
const format = detectSpreadsheetFormat(filename, bytes);
|
||||||
const headerRow = options.headerRow ?? 1;
|
const headerRow = options.headerRow ?? 1;
|
||||||
if (format === "csv") {
|
if (format === "csv") {
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
throw new AdapterError("CSV_INVALID", "O CSV precisa estar em UTF-8.");
|
||||||
|
}
|
||||||
return { format, grid: gridFromRows(parseCsvText(bytes.toString("utf8")), headerRow, ["CSV"], "CSV") };
|
return { format, grid: gridFromRows(parseCsvText(bytes.toString("utf8")), headerRow, ["CSV"], "CSV") };
|
||||||
}
|
}
|
||||||
assertZipSafe(bytes);
|
assertZipSafe(bytes);
|
||||||
@@ -20,10 +25,11 @@ export async function readSpreadsheet(
|
|||||||
try {
|
try {
|
||||||
await workbook.xlsx.load(bytes as unknown as ExcelJS.Buffer);
|
await workbook.xlsx.load(bytes as unknown as ExcelJS.Buffer);
|
||||||
} catch {
|
} catch {
|
||||||
throw new AdapterError("INVALID_STRUCTURE", "Não foi possível ler o XLSX. O arquivo pode estar corrompido.");
|
throw new AdapterError("INVALID_STRUCTURE", "Não foi possível ler o XLSX. Ele pode estar corrompido ou protegido por senha.");
|
||||||
}
|
}
|
||||||
const names = workbook.worksheets.map((sheet) => sheet.name).filter(Boolean);
|
const names = workbook.worksheets.map((sheet) => sheet.name).filter(Boolean);
|
||||||
if (names.length === 0) throw new AdapterError("INVALID_STRUCTURE", "O XLSX não possui abas.");
|
if (names.length === 0) throw new AdapterError("INVALID_STRUCTURE", "O XLSX não possui abas.");
|
||||||
|
if (names.length > MAX_SHEETS) throw new AdapterError("TOO_MANY_SHEETS", `O arquivo excede ${MAX_SHEETS} abas.`);
|
||||||
if (!options.sheet && names.length > 1) {
|
if (!options.sheet && names.length > 1) {
|
||||||
throw new AdapterError("SHEET_REQUIRED", "Selecione exatamente uma aba.", 409, { sheets: names });
|
throw new AdapterError("SHEET_REQUIRED", "Selecione exatamente uma aba.", 409, { sheets: names });
|
||||||
}
|
}
|
||||||
@@ -38,8 +44,13 @@ export async function readSpreadsheet(
|
|||||||
row.eachCell({ includeEmpty: false }, (_cell, column) => {
|
row.eachCell({ includeEmpty: false }, (_cell, column) => {
|
||||||
width = Math.max(width, column);
|
width = Math.max(width, column);
|
||||||
});
|
});
|
||||||
|
if (width > MAX_COLUMNS) throw new AdapterError("TOO_MANY_COLUMNS", `O arquivo excede ${MAX_COLUMNS} colunas.`);
|
||||||
const values: string[] = [];
|
const values: string[] = [];
|
||||||
for (let column = 1; column <= width; column += 1) values.push(row.getCell(column).text ?? "");
|
for (let column = 1; column <= width; column += 1) {
|
||||||
|
const text = row.getCell(column).text ?? "";
|
||||||
|
if (text.length > MAX_CELL_CHARS) throw new AdapterError("CELL_TOO_LARGE", "Uma célula excede o tamanho permitido.");
|
||||||
|
values.push(text);
|
||||||
|
}
|
||||||
matrix.push(values);
|
matrix.push(values);
|
||||||
}
|
}
|
||||||
if (sheet.rowCount > last) {
|
if (sheet.rowCount > last) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { assertProductionEnv } from "@/config/env";
|
||||||
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
|
import { scrubSentryEvent, sentryInitOptions } from "@/infra/observability/capture";
|
||||||
|
|
||||||
export async function register() {
|
export async function register() {
|
||||||
|
assertProductionEnv(process.env);
|
||||||
const options = sentryInitOptions(process.env.SENTRY_DSN);
|
const options = sentryInitOptions(process.env.SENTRY_DSN);
|
||||||
if (!options) return;
|
if (!options) return;
|
||||||
const Sentry = await import("@sentry/nextjs");
|
const Sentry = await import("@sentry/nextjs");
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import type { RequestContext, Role } from "@/domain/spreadsheet-adapter/types";
|
import { adapterPermissions } from "@/domain/spreadsheet-adapter/permissions";
|
||||||
|
import type { AuthenticatedContext, Role } from "@/domain/spreadsheet-adapter/types";
|
||||||
import { getDb } from "@/infra/db/client";
|
import { getDb } from "@/infra/db/client";
|
||||||
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
import { devAuthEnabled, verifyDevSession } from "./dev-session";
|
||||||
|
|
||||||
export type AuthState =
|
export type AuthState =
|
||||||
| { kind: "anonymous" }
|
| { kind: "anonymous" }
|
||||||
|
| { kind: "session_expired" }
|
||||||
| { kind: "no_workspace" }
|
| { kind: "no_workspace" }
|
||||||
| { kind: "not_member" }
|
| { kind: "not_member" }
|
||||||
| { kind: "ok"; ctx: RequestContext };
|
| { kind: "ok"; ctx: AuthenticatedContext };
|
||||||
|
|
||||||
export async function resolveAuth(): Promise<AuthState> {
|
export async function resolveAuth(): Promise<AuthState> {
|
||||||
const jar = await cookies();
|
const jar = await cookies();
|
||||||
let userId: string | null = null;
|
let userId: string | null = null;
|
||||||
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
|
let workspaceId = jar.get("elo_pro_workspace_id")?.value ?? null;
|
||||||
const devToken = jar.get("adapter_dev_session")?.value;
|
const devToken = jar.get("adapter_dev_session")?.value;
|
||||||
|
const hadAuthCookie = jar.getAll().some((cookie) => cookie.name.startsWith("sb-") && cookie.name.includes("auth"));
|
||||||
if (devToken && devAuthEnabled()) {
|
if (devToken && devAuthEnabled()) {
|
||||||
const dev = verifyDevSession(devToken);
|
const dev = verifyDevSession(devToken);
|
||||||
if (dev) {
|
if (dev) {
|
||||||
@@ -37,7 +40,7 @@ export async function resolveAuth(): Promise<AuthState> {
|
|||||||
const { data } = await supabase.auth.getUser();
|
const { data } = await supabase.auth.getUser();
|
||||||
userId = data.user?.id ?? null;
|
userId = data.user?.id ?? null;
|
||||||
}
|
}
|
||||||
if (!userId) return { kind: "anonymous" };
|
if (!userId) return { kind: hadAuthCookie || Boolean(devToken) ? "session_expired" : "anonymous" };
|
||||||
if (!workspaceId) return { kind: "no_workspace" };
|
if (!workspaceId) return { kind: "no_workspace" };
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const rows = await db.withUser(userId, (query) =>
|
const rows = await db.withUser(userId, (query) =>
|
||||||
@@ -63,9 +66,7 @@ export async function resolveAuth(): Promise<AuthState> {
|
|||||||
);
|
);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) return { kind: "not_member" };
|
if (!row) return { kind: "not_member" };
|
||||||
return {
|
const ctx = {
|
||||||
kind: "ok",
|
|
||||||
ctx: {
|
|
||||||
userId,
|
userId,
|
||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
isSuperadmin: row.is_superadmin,
|
isSuperadmin: row.is_superadmin,
|
||||||
@@ -74,6 +75,6 @@ export async function resolveAuth(): Promise<AuthState> {
|
|||||||
role: row.role,
|
role: row.role,
|
||||||
moduleEnabled: row.module_enabled,
|
moduleEnabled: row.module_enabled,
|
||||||
allowNonAdminModelUpload: row.allow_upload,
|
allowNonAdminModelUpload: row.allow_upload,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
return { kind: "ok", ctx: { ...ctx, permissions: adapterPermissions(ctx) } };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Pontos de integração com a identidade Elo Pro.
|
||||||
|
* Os nomes abaixo são o contrato que o Adaptador lê hoje no shim local.
|
||||||
|
* Eles NÃO estão confirmados contra o banco real da Elo Pro.
|
||||||
|
* Quando o repositório real estiver disponível, este arquivo é o único
|
||||||
|
* lugar que deve mudar para apontar às tabelas existentes. Não criar users,
|
||||||
|
* senha, cadastro ou papel paralelo.
|
||||||
|
*/
|
||||||
|
export const eloProIdentityContract = {
|
||||||
|
authUsers: "auth.users",
|
||||||
|
profile: { table: "public.profiles", status: "unconfirmed_against_real_elo_pro" },
|
||||||
|
workspace: { table: "public.workspaces", status: "unconfirmed_against_real_elo_pro" },
|
||||||
|
membership: { table: "public.workspace_members", status: "unconfirmed_against_real_elo_pro" },
|
||||||
|
moduleFlag: { table: "public.workspace_module_flags", key: "spreadsheet_adapter", status: "adapter_specific" },
|
||||||
|
workspaceCookie: "elo_pro_workspace_id",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export function allowedReturnHosts(env: Record<string, string | undefined> = process.env): string[] {
|
||||||
|
const hosts = (env.AUTH_ALLOWED_RETURN_HOSTS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((host) => host.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (env.ELO_PRO_APP_URL) {
|
||||||
|
try {
|
||||||
|
hosts.push(new URL(env.ELO_PRO_APP_URL).host);
|
||||||
|
} catch {
|
||||||
|
/* host inválido não entra na lista */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(hosts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aceita somente caminho relativo interno. Host externo é recusado. */
|
||||||
|
export function safeReturnPath(input: string | null | undefined): string {
|
||||||
|
if (!input) return "/adaptador";
|
||||||
|
if (!input.startsWith("/") || input.startsWith("//") || input.includes("\\") || input.includes("://")) {
|
||||||
|
return "/adaptador";
|
||||||
|
}
|
||||||
|
const path = input.slice(0, 300);
|
||||||
|
return path.startsWith("/login") ? "/adaptador" : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCentralLoginUrl(loginUrl: string, returnPath: string, appUrl?: string): string {
|
||||||
|
const target = new URL(loginUrl);
|
||||||
|
const path = safeReturnPath(returnPath);
|
||||||
|
if (!appUrl) {
|
||||||
|
target.searchParams.set("returnTo", path);
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
|
const app = new URL(appUrl);
|
||||||
|
const absolute = new URL(path, app);
|
||||||
|
if (absolute.origin !== app.origin) return target.toString();
|
||||||
|
target.searchParams.set("returnTo", absolute.toString());
|
||||||
|
return target.toString();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||||
|
import { shouldRequeue } from "@/domain/spreadsheet-adapter/failure-policy";
|
||||||
import { neutralizeRow, neutralizeSpreadsheetFormula } from "@/domain/spreadsheet-adapter/csv-injection";
|
import { neutralizeRow, neutralizeSpreadsheetFormula } from "@/domain/spreadsheet-adapter/csv-injection";
|
||||||
import { buildOutputFilename } from "@/domain/spreadsheet-adapter/filenames";
|
import { buildOutputFilename } from "@/domain/spreadsheet-adapter/filenames";
|
||||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||||
@@ -22,6 +23,12 @@ function assertUse(ctx: RequestContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertOwnedTempKey(workspaceId: string, key: string): void {
|
||||||
|
if (!key.startsWith(`workspaces/${workspaceId}/tmp/`) || !key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
|
||||||
|
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function audit(
|
async function audit(
|
||||||
query: Query,
|
query: Query,
|
||||||
ctx: RequestContext,
|
ctx: RequestContext,
|
||||||
@@ -37,15 +44,18 @@ async function audit(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertTempKey(key: string): void {
|
function processTimeoutMs(): number {
|
||||||
if (!key.includes("/tmp/") || key.includes("/models/") || key.includes("..")) {
|
const raw = process.env.SPREADSHEET_JOB_TIMEOUT_MS;
|
||||||
throw new AdapterError("PATH_TRAVERSAL", "Chave temporária rejeitada.");
|
if (!raw) return 120_000;
|
||||||
}
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : 120_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<void> {
|
export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<void> {
|
||||||
const userLock = lockKey(ctx.userId);
|
const userLock = lockKey(ctx.userId);
|
||||||
const written: string[] = [];
|
const written: string[] = [];
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const timeoutMs = processTimeoutMs();
|
||||||
try {
|
try {
|
||||||
const owns = await deps.coordinator.ownsLock(userLock, jobId);
|
const owns = await deps.coordinator.ownsLock(userLock, jobId);
|
||||||
if (!owns) {
|
if (!owns) {
|
||||||
@@ -120,8 +130,14 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
|
|||||||
).map(neutralizeRow);
|
).map(neutralizeRow);
|
||||||
const headers = columns.map((column) => neutralizeSpreadsheetFormula(column.name));
|
const headers = columns.map((column) => neutralizeSpreadsheetFormula(column.name));
|
||||||
let done = 0;
|
let done = 0;
|
||||||
|
if (Date.now() - startedAt >= timeoutMs) {
|
||||||
|
throw new AdapterError("TIMEOUT", "O processamento excedeu o tempo limite.", 504);
|
||||||
|
}
|
||||||
for (const _row of transformed) {
|
for (const _row of transformed) {
|
||||||
done += 1;
|
done += 1;
|
||||||
|
if (Date.now() - startedAt >= timeoutMs) {
|
||||||
|
throw new AdapterError("TIMEOUT", "O processamento excedeu o tempo limite.", 504);
|
||||||
|
}
|
||||||
if (done % 5000 === 0) {
|
if (done % 5000 === 0) {
|
||||||
await progress({
|
await progress({
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
@@ -136,8 +152,8 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
|
|||||||
await progress({ phase: "generating", label: "Gerando arquivo", percent: null, sourceIndex, sourceCount: total, sourceId });
|
await progress({ phase: "generating", label: "Gerando arquivo", percent: null, sourceIndex, sourceCount: total, sourceId });
|
||||||
const csvKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.csv`;
|
const csvKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.csv`;
|
||||||
const xlsxKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.xlsx`;
|
const xlsxKey = `workspaces/${ctx.workspaceId}/tmp/${jobId}/${sourceId}/out.xlsx`;
|
||||||
assertTempKey(csvKey);
|
assertOwnedTempKey(ctx.workspaceId, csvKey);
|
||||||
assertTempKey(xlsxKey);
|
assertOwnedTempKey(ctx.workspaceId, xlsxKey);
|
||||||
const csv = buildCsv(headers, transformed);
|
const csv = buildCsv(headers, transformed);
|
||||||
const xlsx = await buildXlsx(headers, transformed);
|
const xlsx = await buildXlsx(headers, transformed);
|
||||||
await deps.storage.put(csvKey, csv);
|
await deps.storage.put(csvKey, csv);
|
||||||
@@ -180,28 +196,38 @@ export async function runProcess(deps: AppDeps, ctx: RequestContext, jobId: stri
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
for (const key of written) await deps.storage.delete(key).catch(() => undefined);
|
for (const key of written) await deps.storage.delete(key).catch(() => undefined);
|
||||||
const code = isAdapterError(error) ? error.code : "PROCESS_FAILED";
|
const code = isAdapterError(error) ? error.code : "PROCESS_FAILED";
|
||||||
const failed: JobProgress = {
|
const httpStatus = isAdapterError(error) ? error.status : 500;
|
||||||
phase: "failed",
|
let requeue = false;
|
||||||
label: "Falhou",
|
await deps.db
|
||||||
|
.withUser(ctx.userId, async (query) => {
|
||||||
|
const current = await query.query<{ progress: unknown }>(`select progress from public.spreadsheet_jobs where id = $1`, [jobId]);
|
||||||
|
const prev = asJson<JobProgress | null>(current[0]?.progress, null);
|
||||||
|
const attempt = (prev?.attempt ?? 0) + 1;
|
||||||
|
requeue = shouldRequeue(code, httpStatus, attempt);
|
||||||
|
const next: JobProgress = {
|
||||||
|
phase: requeue ? "validating" : "failed",
|
||||||
|
label: requeue ? "Nova tentativa" : "Falhou",
|
||||||
percent: null,
|
percent: null,
|
||||||
sourceIndex: 1,
|
sourceIndex: 1,
|
||||||
sourceCount: 1,
|
sourceCount: 1,
|
||||||
sourceId,
|
sourceId,
|
||||||
|
attempt,
|
||||||
};
|
};
|
||||||
await deps.db
|
|
||||||
.withUser(ctx.userId, async (query) => {
|
|
||||||
await query.query(`update public.spreadsheet_job_sources set status = 'failed', error_code = $2 where id = $1 and status <> 'completed'`, [
|
|
||||||
sourceId,
|
|
||||||
code,
|
|
||||||
]);
|
|
||||||
await query.query(
|
await query.query(
|
||||||
`update public.spreadsheet_jobs set status = 'failed', error_code = $2, progress = $3::jsonb, finished_at = now(), updated_at = now() where id = $1`,
|
`update public.spreadsheet_job_sources set status = $2, error_code = $3 where id = $1 and status <> 'completed'`,
|
||||||
[jobId, code, JSON.stringify(failed)],
|
[sourceId, requeue ? "mapped" : "failed", code],
|
||||||
);
|
);
|
||||||
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId });
|
await query.query(
|
||||||
|
`update public.spreadsheet_jobs
|
||||||
|
set status = $2, error_code = $3, progress = $4::jsonb,
|
||||||
|
finished_at = case when $2 = 'failed' then now() else finished_at end, updated_at = now()
|
||||||
|
where id = $1`,
|
||||||
|
[jobId, requeue ? "queued" : "failed", code, JSON.stringify(next)],
|
||||||
|
);
|
||||||
|
await audit(query, ctx, "job.failed", jobId, "error", { error_code: code, source_id: sourceId, attempt });
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
await deps.coordinator.setProgress(jobId, failed).catch(() => undefined);
|
if (requeue) await deps.coordinator.enqueue(jobId).catch(() => undefined);
|
||||||
if (!isAdapterError(error) || error.status >= 500) {
|
if (!isAdapterError(error) || error.status >= 500) {
|
||||||
deps.capture?.(error, { module: "spreadsheet-adapter", job_id: jobId, workspace_id: ctx.workspaceId, error_code: code });
|
deps.capture?.(error, { module: "spreadsheet-adapter", job_id: jobId, workspace_id: ctx.workspaceId, error_code: code });
|
||||||
}
|
}
|
||||||
@@ -220,6 +246,16 @@ const CONTENT_TYPE: Record<OutputFormat, string> = {
|
|||||||
|
|
||||||
export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, format: OutputFormat) {
|
export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, format: OutputFormat) {
|
||||||
assertUse(ctx);
|
assertUse(ctx);
|
||||||
|
const flags = await deps.db.withUser(ctx.userId, (query) =>
|
||||||
|
query.query<{ enabled: boolean }>(
|
||||||
|
`select enabled from public.workspace_module_flags where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
|
||||||
|
[ctx.workspaceId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const flag = flags[0]?.enabled as unknown;
|
||||||
|
if (flag !== true && flag !== "t" && flag !== "true") {
|
||||||
|
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||||
|
}
|
||||||
const file = await deps.db.withUser(ctx.userId, async (query) => {
|
const file = await deps.db.withUser(ctx.userId, async (query) => {
|
||||||
const rows = await query.query<{
|
const rows = await query.query<{
|
||||||
model_name: string;
|
model_name: string;
|
||||||
@@ -242,7 +278,7 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
|||||||
const filename = buildOutputFilename(file.model_name, format, deps.now?.() ?? new Date());
|
const filename = buildOutputFilename(file.model_name, format, deps.now?.() ?? new Date());
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
if (!file.output_csv_key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
|
if (!file.output_csv_key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
|
||||||
assertTempKey(file.output_csv_key);
|
assertOwnedTempKey(ctx.workspaceId, file.output_csv_key);
|
||||||
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
|
const csv = (await deps.storage.get(file.output_csv_key)).toString("utf8");
|
||||||
const grid = parseCsvText(csv);
|
const grid = parseCsvText(csv);
|
||||||
const headers = grid[0] ?? [];
|
const headers = grid[0] ?? [];
|
||||||
@@ -251,29 +287,35 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
|||||||
}
|
}
|
||||||
const key = format === "csv" ? file.output_csv_key : file.output_xlsx_key;
|
const key = format === "csv" ? file.output_csv_key : file.output_xlsx_key;
|
||||||
if (!key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
|
if (!key) throw new AdapterError("NOT_FOUND", "Saída indisponível.", 404);
|
||||||
assertTempKey(key);
|
assertOwnedTempKey(ctx.workspaceId, key);
|
||||||
return { filename, contentType: CONTENT_TYPE[format], body: await deps.storage.get(key) };
|
return { filename, contentType: CONTENT_TYPE[format], body: await deps.storage.get(key) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
||||||
const rows = await deps.db.withService((query) =>
|
const rows = await deps.db.withService((query) =>
|
||||||
query.query<{ id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
|
query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>(
|
||||||
`select id, temp_storage_key, output_csv_key, output_xlsx_key
|
`select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key
|
||||||
from public.spreadsheet_job_sources
|
from public.spreadsheet_job_sources s
|
||||||
where expires_at < $1::timestamptz and status <> 'expired'`,
|
join public.spreadsheet_jobs j on j.id = s.job_id
|
||||||
|
where s.expires_at < $1::timestamptz and s.status <> 'expired'`,
|
||||||
[(deps.now ?? (() => new Date()))().toISOString()],
|
[(deps.now ?? (() => new Date()))().toISOString()],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
let removed = 0;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
const prefix = `workspaces/${row.workspace_id}/tmp/`;
|
||||||
for (const key of [row.temp_storage_key, row.output_csv_key, row.output_xlsx_key]) {
|
for (const key of [row.temp_storage_key, row.output_csv_key, row.output_xlsx_key]) {
|
||||||
if (!key || !key.includes("/tmp/") || key.includes("/models/")) continue;
|
if (!key || !key.startsWith(prefix) || key.includes("..") || key.includes("/models/")) continue;
|
||||||
await deps.storage.delete(key).catch(() => undefined);
|
await deps.storage.delete(key).catch((error) => {
|
||||||
|
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await deps.db.withService((query) =>
|
await deps.db.withService((query) =>
|
||||||
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
|
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
|
||||||
);
|
);
|
||||||
|
removed += 1;
|
||||||
}
|
}
|
||||||
return rows.length;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { jobRunsInline } from "@/config/env";
|
||||||
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||||
import { MAX_ACTIVE_MODELS, MAX_SOURCES_PER_JOB } from "@/domain/spreadsheet-adapter/limits";
|
import { MAX_ACTIVE_MODELS, MAX_SOURCES_PER_JOB } from "@/domain/spreadsheet-adapter/limits";
|
||||||
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
|
||||||
@@ -26,11 +27,22 @@ function ttl(deps: AppDeps): number {
|
|||||||
return deps.ttlSeconds ?? tempTtlSeconds();
|
return deps.ttlSeconds ?? tempTtlSeconds();
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertUse(ctx: RequestContext): void {
|
async function assertUse(deps: AppDeps, ctx: RequestContext): Promise<void> {
|
||||||
if (!ctx.moduleEnabled || !canUseSpreadsheetAdapter(ctx)) {
|
if (!ctx.moduleEnabled || !canUseSpreadsheetAdapter(ctx)) {
|
||||||
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||||
}
|
}
|
||||||
if (!canOperateSpreadsheet(ctx)) throw new AdapterError("FORBIDDEN", "Sem permissão para operar o Adaptador.", 403);
|
if (!canOperateSpreadsheet(ctx)) throw new AdapterError("FORBIDDEN", "Sem permissão para operar o Adaptador.", 403);
|
||||||
|
const flags = await deps.db.withUser(ctx.userId, (query) =>
|
||||||
|
query.query<{ enabled: boolean }>(
|
||||||
|
`select enabled from public.workspace_module_flags
|
||||||
|
where workspace_id = $1 and module_key = 'spreadsheet_adapter'`,
|
||||||
|
[ctx.workspaceId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const flag = flags[0]?.enabled as unknown;
|
||||||
|
if (flag !== true && flag !== "t" && flag !== "true") {
|
||||||
|
throw new AdapterError("FEATURE_DISABLED", "O Adaptador não está habilitado neste workspace.", 403);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function audit(
|
async function audit(
|
||||||
@@ -60,12 +72,12 @@ export async function rejectWorkspaceForgery(deps: AppDeps, ctx: RequestContext,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listModels(deps: AppDeps, ctx: RequestContext): Promise<ModelDto[]> {
|
export async function listModels(deps: AppDeps, ctx: RequestContext): Promise<ModelDto[]> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
return deps.db.withUser(ctx.userId, (query) => readModels(query, ctx.workspaceId));
|
return deps.db.withUser(ctx.userId, (query) => readModels(query, ctx.workspaceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{ allowNonAdminModelUpload: boolean }> {
|
export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{ allowNonAdminModelUpload: boolean }> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
const rows = await deps.db.withUser(ctx.userId, (query) =>
|
const rows = await deps.db.withUser(ctx.userId, (query) =>
|
||||||
query.query<{ allow_non_admin_model_upload: boolean }>(
|
query.query<{ allow_non_admin_model_upload: boolean }>(
|
||||||
`select allow_non_admin_model_upload from public.spreadsheet_adapter_settings where workspace_id = $1`,
|
`select allow_non_admin_model_upload from public.spreadsheet_adapter_settings where workspace_id = $1`,
|
||||||
@@ -76,7 +88,7 @@ export async function getSettings(deps: AppDeps, ctx: RequestContext): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNonAdminModelUpload: boolean): Promise<void> {
|
export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNonAdminModelUpload: boolean): Promise<void> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
if (!canManageSpreadsheetModels(ctx)) {
|
if (!canManageSpreadsheetModels(ctx)) {
|
||||||
throw new AdapterError("FORBIDDEN", "Somente owner ou admin alteram a configuração do módulo.", 403);
|
throw new AdapterError("FORBIDDEN", "Somente owner ou admin alteram a configuração do módulo.", 403);
|
||||||
}
|
}
|
||||||
@@ -95,7 +107,7 @@ export async function updateSettings(deps: AppDeps, ctx: RequestContext, allowNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createModel(deps: AppDeps, ctx: RequestContext, input: ModelCreateInput): Promise<ModelDto> {
|
export async function createModel(deps: AppDeps, ctx: RequestContext, input: ModelCreateInput): Promise<ModelDto> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
if (!canUploadSpreadsheetModel(ctx)) {
|
if (!canUploadSpreadsheetModel(ctx)) {
|
||||||
throw new AdapterError("FORBIDDEN", "Seu perfil não pode cadastrar modelos neste workspace.", 403);
|
throw new AdapterError("FORBIDDEN", "Seu perfil não pode cadastrar modelos neste workspace.", 403);
|
||||||
}
|
}
|
||||||
@@ -158,7 +170,7 @@ export async function createModel(deps: AppDeps, ctx: RequestContext, input: Mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: string): Promise<void> {
|
export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: string): Promise<void> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
if (!canManageSpreadsheetModels(ctx)) {
|
if (!canManageSpreadsheetModels(ctx)) {
|
||||||
throw new AdapterError("FORBIDDEN", "Somente owner ou admin podem excluir modelos.", 403);
|
throw new AdapterError("FORBIDDEN", "Somente owner ou admin podem excluir modelos.", 403);
|
||||||
}
|
}
|
||||||
@@ -180,7 +192,7 @@ export async function deleteModel(deps: AppDeps, ctx: RequestContext, modelId: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createJob(deps: AppDeps, ctx: RequestContext, modelId: string, idempotencyKey?: string | null): Promise<JobDto> {
|
export async function createJob(deps: AppDeps, ctx: RequestContext, modelId: string, idempotencyKey?: string | null): Promise<JobDto> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
const jobId = await deps.db.withUser(ctx.userId, async (query) => {
|
const jobId = await deps.db.withUser(ctx.userId, async (query) => {
|
||||||
if (idempotencyKey) {
|
if (idempotencyKey) {
|
||||||
const existing = await query.query<{ id: string }>(
|
const existing = await query.query<{ id: string }>(
|
||||||
@@ -219,7 +231,7 @@ async function ensureMutableJob(query: Query, ctx: RequestContext, jobId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: string, file: UploadFile, sheet?: string | null, headerRow = 1) {
|
export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: string, file: UploadFile, sheet?: string | null, headerRow = 1) {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
const filename = sanitizeDisplayName(file.filename);
|
const filename = sanitizeDisplayName(file.filename);
|
||||||
let parsed: Awaited<ReturnType<typeof readSpreadsheet>> | null = null;
|
let parsed: Awaited<ReturnType<typeof readSpreadsheet>> | null = null;
|
||||||
let sheetRequired: string[] | null = null;
|
let sheetRequired: string[] | null = null;
|
||||||
@@ -274,6 +286,10 @@ export async function addSource(deps: AppDeps, ctx: RequestContext, jobId: strin
|
|||||||
`update public.spreadsheet_jobs set source_count = source_count + 1, updated_at = now() where id = $1`,
|
`update public.spreadsheet_jobs set source_count = source_count + 1, updated_at = now() where id = $1`,
|
||||||
[jobId],
|
[jobId],
|
||||||
);
|
);
|
||||||
|
await audit(query, ctx, "source.uploaded", "job_source", sourceId, status === "invalid" ? "error" : "success", {
|
||||||
|
size_bytes: file.bytes.length,
|
||||||
|
row_count: parsed?.grid.rows.length ?? 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await deps.storage.delete(storageKey);
|
await deps.storage.delete(storageKey);
|
||||||
@@ -296,7 +312,7 @@ export async function updateSource(
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
input: { sheet?: string | null; headerRow?: number },
|
input: { sheet?: string | null; headerRow?: number },
|
||||||
) {
|
) {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
await deps.db.withUser(ctx.userId, async (query) => {
|
await deps.db.withUser(ctx.userId, async (query) => {
|
||||||
await ensureMutableJob(query, ctx, jobId);
|
await ensureMutableJob(query, ctx, jobId);
|
||||||
const rows = await query.query<{ temp_storage_key: string; source_name: string; header_row: number; selected_sheet: string | null }>(
|
const rows = await query.query<{ temp_storage_key: string; source_name: string; header_row: number; selected_sheet: string | null }>(
|
||||||
@@ -337,7 +353,7 @@ export async function updateSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<JobDto> {
|
export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string): Promise<JobDto> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
const key = await deps.db.withUser(ctx.userId, async (query) => {
|
const key = await deps.db.withUser(ctx.userId, async (query) => {
|
||||||
await ensureMutableJob(query, ctx, jobId);
|
await ensureMutableJob(query, ctx, jobId);
|
||||||
const rows = await query.query<{ temp_storage_key: string; status: string }>(
|
const rows = await query.query<{ temp_storage_key: string; status: string }>(
|
||||||
@@ -355,7 +371,7 @@ export async function removeSource(deps: AppDeps, ctx: RequestContext, jobId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, mappings: MappingInput[]) {
|
export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, mappings: MappingInput[]) {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
await deps.db.withUser(ctx.userId, async (query) => {
|
await deps.db.withUser(ctx.userId, async (query) => {
|
||||||
await ensureMutableJob(query, ctx, jobId);
|
await ensureMutableJob(query, ctx, jobId);
|
||||||
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
||||||
@@ -387,12 +403,16 @@ export async function saveMappings(deps: AppDeps, ctx: RequestContext, jobId: st
|
|||||||
[jobId],
|
[jobId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await audit(query, ctx, "mapping.updated", "job_source", sourceId, "success", {
|
||||||
|
mapping_count: mappings.length,
|
||||||
|
complete: result.ok,
|
||||||
|
});
|
||||||
}).catch(mapDbError);
|
}).catch(mapDbError);
|
||||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string) {
|
export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string) {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
return deps.db.withUser(ctx.userId, async (query) => {
|
return deps.db.withUser(ctx.userId, async (query) => {
|
||||||
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
const job = await readJob(query, deps.coordinator, ctx, jobId);
|
||||||
const source = job.sources.find((item) => item.id === sourceId);
|
const source = job.sources.find((item) => item.id === sourceId);
|
||||||
@@ -427,13 +447,14 @@ export async function previewSource(deps: AppDeps, ctx: RequestContext, jobId: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inlineMode(): boolean {
|
function inlineMode(): boolean {
|
||||||
if (process.env.ADAPTER_JOB_MODE === "worker") return false;
|
if (process.env.NODE_ENV === "production" && process.env.ADAPTER_JOB_MODE === "inline") {
|
||||||
if (process.env.ADAPTER_JOB_MODE === "inline") return true;
|
throw new AdapterError("INVALID_SETTING", "Processamento inline não é permitido em produção.", 500);
|
||||||
return process.env.NODE_ENV !== "production";
|
}
|
||||||
|
return jobRunsInline();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, idempotencyKey: string): Promise<JobDto> {
|
export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: string, sourceId: string, idempotencyKey: string): Promise<JobDto> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
if (!idempotencyKey || idempotencyKey.length > 120) {
|
if (!idempotencyKey || idempotencyKey.length > 120) {
|
||||||
throw new AdapterError("IDEMPOTENCY_REQUIRED", "Informe uma chave de idempotência para processar.", 400);
|
throw new AdapterError("IDEMPOTENCY_REQUIRED", "Informe uma chave de idempotência para processar.", 400);
|
||||||
}
|
}
|
||||||
@@ -501,12 +522,13 @@ export async function startProcess(deps: AppDeps, ctx: RequestContext, jobId: st
|
|||||||
);
|
);
|
||||||
await audit(query, ctx, "job.started", "job", jobId, "success", { source_id: sourceId });
|
await audit(query, ctx, "job.started", "job", jobId, "success", { source_id: sourceId });
|
||||||
});
|
});
|
||||||
|
if (!inlineMode()) await deps.coordinator.enqueue(jobId).catch(() => undefined);
|
||||||
if (inlineMode()) await runProcess(deps, ctx, jobId, sourceId);
|
if (inlineMode()) await runProcess(deps, ctx, jobId, sourceId);
|
||||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJob(deps: AppDeps, ctx: RequestContext, jobId: string): Promise<JobDto> {
|
export async function getJob(deps: AppDeps, ctx: RequestContext, jobId: string): Promise<JobDto> {
|
||||||
assertUse(ctx);
|
await assertUse(deps, ctx);
|
||||||
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
return deps.db.withUser(ctx.userId, (query) => readJob(query, deps.coordinator, ctx, jobId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("RLS e permissões", () => {
|
|||||||
),
|
),
|
||||||
).rejects.toThrow(/row-level security/i);
|
).rejects.toThrow(/row-level security/i);
|
||||||
await expect(createModel(deps, { ...diego, moduleEnabled: true }, { name: "Beta", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toMatchObject({
|
await expect(createModel(deps, { ...diego, moduleEnabled: true }, { name: "Beta", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toMatchObject({
|
||||||
code: "FORBIDDEN",
|
code: "FEATURE_DISABLED",
|
||||||
});
|
});
|
||||||
const eva = ctxOf("eva", "beta", "agent", true, true);
|
const eva = ctxOf("eva", "beta", "agent", true, true);
|
||||||
await expect(createModel(deps, eva, { name: "Super", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toThrow();
|
await expect(createModel(deps, eva, { name: "Super", filename: "a.csv", bytes: Buffer.from("A\n1\n") })).rejects.toThrow();
|
||||||
@@ -49,4 +49,26 @@ describe("RLS e permissões", () => {
|
|||||||
expect(seen[0].allow_non_admin_model_upload).toBe(true);
|
expect(seen[0].allow_non_admin_model_upload).toBe(true);
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("não altera auditoria pelo papel da aplicação", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
await db.withUser(ana.userId, (query) =>
|
||||||
|
query.query(
|
||||||
|
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
|
||||||
|
values ($1, $2, 'model.uploaded', 'model', 'm1', 'success')`,
|
||||||
|
[IDS.alpha, IDS.ana],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
db.withUser(ana.userId, (query) =>
|
||||||
|
query.query(`update public.audit_log set action = 'tamper' where workspace_id = $1`, [IDS.alpha]),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/permission denied/i);
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ action: string }>(`select action from public.audit_log where workspace_id = $1`, [IDS.alpha]),
|
||||||
|
);
|
||||||
|
expect(rows.every((row) => row.action !== "tamper")).toBe(true);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||||
|
import { buildCsv, buildXlsx } from "@/infra/export/tabular";
|
||||||
|
|
||||||
|
function csv(rows: number): Buffer {
|
||||||
|
const lines = ["Nome,Valor"];
|
||||||
|
for (let index = 0; index < rows; index += 1) lines.push(`Pessoa ${index},${index}`);
|
||||||
|
return Buffer.from(lines.join("\n"), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measure(rows: number) {
|
||||||
|
const bytes = csv(rows);
|
||||||
|
const started = performance.now();
|
||||||
|
const parsed = await readSpreadsheet("amostra.csv", bytes);
|
||||||
|
const readMs = performance.now() - started;
|
||||||
|
const writeStarted = performance.now();
|
||||||
|
buildCsv(parsed.grid.headers, parsed.grid.rows);
|
||||||
|
const csvMs = performance.now() - writeStarted;
|
||||||
|
const xlsxStarted = performance.now();
|
||||||
|
await buildXlsx(parsed.grid.headers, parsed.grid.rows);
|
||||||
|
const xlsxMs = performance.now() - xlsxStarted;
|
||||||
|
return { rows, readMs, csvMs, xlsxMs, heapMb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("baseline de planilha", () => {
|
||||||
|
it("mede leitura e escrita em amostra pequena e média", async () => {
|
||||||
|
const small = await measure(200);
|
||||||
|
const medium = await measure(5_000);
|
||||||
|
expect(small.readMs).toBeLessThan(5_000);
|
||||||
|
expect(medium.readMs).toBeLessThan(15_000);
|
||||||
|
expect(medium.xlsxMs).toBeLessThan(30_000);
|
||||||
|
expect(small.rows).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
|
||||||
|
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||||
|
import { assertZipSafe } from "@/domain/spreadsheet-adapter/file-signature";
|
||||||
|
import {
|
||||||
|
addSource,
|
||||||
|
createJob,
|
||||||
|
createModel,
|
||||||
|
listModels,
|
||||||
|
saveMappings,
|
||||||
|
startProcess,
|
||||||
|
} from "@/server/spreadsheet-adapter/service";
|
||||||
|
import { cleanupExpired, downloadOutput, runProcess } from "@/server/spreadsheet-adapter/process-job";
|
||||||
|
import type { ObjectStorage } from "@/infra/storage/memory";
|
||||||
|
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
||||||
|
|
||||||
|
function zipEntry(name: string): Buffer {
|
||||||
|
const entry = Buffer.from(name);
|
||||||
|
const header = Buffer.alloc(30);
|
||||||
|
header.writeUInt32LE(0x04034b50, 0);
|
||||||
|
header.writeUInt16LE(entry.length, 26);
|
||||||
|
return Buffer.concat([header, entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isolamento e arquivos hostis", () => {
|
||||||
|
it("impede download, listagem e flag forjada entre workspaces", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const deps = depsOf(db);
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
const bruno = ctxOf("bruno", "alpha", "agent", true);
|
||||||
|
const diego = ctxOf("diego", "beta", "admin", true);
|
||||||
|
const model = await createModel(deps, ana, {
|
||||||
|
name: "Isolado",
|
||||||
|
filename: "modelo.csv",
|
||||||
|
bytes: Buffer.from("Nome,Valor\n", "utf8"),
|
||||||
|
requiredColumns: ["Nome", "Valor"],
|
||||||
|
});
|
||||||
|
const job = await createJob(deps, ana, model.id, "job-iso");
|
||||||
|
const source = await addSource(deps, ana, job.id, {
|
||||||
|
filename: "fonte.csv",
|
||||||
|
bytes: Buffer.from("Nome,Valor\nAna,10\n", "utf8"),
|
||||||
|
});
|
||||||
|
if (!("job" in source)) throw new Error("fonte");
|
||||||
|
const sourceId = source.job.sources[0]?.id;
|
||||||
|
if (!sourceId) throw new Error("fonte id");
|
||||||
|
await saveMappings(deps, ana, job.id, sourceId, [
|
||||||
|
{ sourceColumn: "Nome", modelColumn: "Nome" },
|
||||||
|
{ sourceColumn: "Valor", modelColumn: "Valor" },
|
||||||
|
]);
|
||||||
|
const done = await startProcess(deps, ana, job.id, sourceId, "proc-iso");
|
||||||
|
expect(done.status).toBe("completed");
|
||||||
|
await expect(downloadOutput(deps, bruno, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
await expect(downloadOutput(deps, diego, job.id, sourceId, "xlsx")).rejects.toMatchObject({ code: "FEATURE_DISABLED" });
|
||||||
|
await expect(listModels(deps, diego)).rejects.toMatchObject({ code: "FEATURE_DISABLED" });
|
||||||
|
await db.withService((query) =>
|
||||||
|
query.query(`update public.workspace_module_flags set enabled = false where workspace_id = $1`, [IDS.alpha]),
|
||||||
|
);
|
||||||
|
await expect(downloadOutput(deps, ana, job.id, sourceId, "csv")).rejects.toMatchObject({ code: "FEATURE_DISABLED" });
|
||||||
|
await db.withService((query) =>
|
||||||
|
query.query(`update public.workspace_module_flags set enabled = true where workspace_id = $1`, [IDS.alpha]),
|
||||||
|
);
|
||||||
|
const outsider = { ...ana, workspaceId: "33333333-3333-4333-8333-333333333333" };
|
||||||
|
await db.withService((query) =>
|
||||||
|
query.query(`insert into public.workspaces (id, name) values ($1, 'Gamma')`, [outsider.workspaceId]),
|
||||||
|
);
|
||||||
|
await db.withService((query) =>
|
||||||
|
query.query(
|
||||||
|
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) values ($1, 'spreadsheet_adapter', true)`,
|
||||||
|
[outsider.workspaceId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(listModels(deps, outsider)).rejects.toBeInstanceOf(AdapterError);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mantém o arquivo do outro workspace na limpeza", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const deps = depsOf(db);
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
const model = await createModel(deps, ana, {
|
||||||
|
name: "TTL",
|
||||||
|
filename: "modelo.csv",
|
||||||
|
bytes: Buffer.from("Nome\n", "utf8"),
|
||||||
|
requiredColumns: ["Nome"],
|
||||||
|
});
|
||||||
|
const job = await createJob(deps, ana, model.id, "job-ttl");
|
||||||
|
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||||
|
if (!("job" in source)) throw new Error("fonte");
|
||||||
|
const sourceId = source.job.sources[0]?.id ?? "";
|
||||||
|
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||||
|
await startProcess(deps, ana, job.id, sourceId, "proc-ttl");
|
||||||
|
const foreign = `workspaces/${IDS.beta}/tmp/outro/source`;
|
||||||
|
await deps.storage.put(foreign, Buffer.from("nao-apagar"));
|
||||||
|
deps.ttlSeconds = -1;
|
||||||
|
await cleanupExpired(deps);
|
||||||
|
await cleanupExpired(deps);
|
||||||
|
expect(await deps.storage.get(foreign)).toEqual(Buffer.from("nao-apagar"));
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("duas transferências simultâneas do mesmo usuário terminam em um único resultado", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const deps = depsOf(db);
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
const model = await createModel(deps, ana, {
|
||||||
|
name: "Concorrencia",
|
||||||
|
filename: "modelo.csv",
|
||||||
|
bytes: Buffer.from("Nome\n", "utf8"),
|
||||||
|
requiredColumns: ["Nome"],
|
||||||
|
});
|
||||||
|
const job = await createJob(deps, ana, model.id, "job-lock");
|
||||||
|
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||||
|
if (!("job" in source)) throw new Error("fonte");
|
||||||
|
const sourceId = source.job.sources[0]?.id ?? "";
|
||||||
|
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
startProcess(deps, ana, job.id, sourceId, "mesmo"),
|
||||||
|
startProcess(deps, ana, job.id, sourceId, "mesmo"),
|
||||||
|
]);
|
||||||
|
for (const item of settled) {
|
||||||
|
if (item.status === "rejected") expect((item.reason as AdapterError).code).toMatch(/JOB_LOCKED|ALREADY_COMPLETED/);
|
||||||
|
else expect(item.value.status === "completed" || item.value.status === "processing" || item.value.status === "queued").toBe(true);
|
||||||
|
}
|
||||||
|
const again = await startProcess(deps, ana, job.id, sourceId, "mesmo");
|
||||||
|
expect(again.status).toBe("completed");
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reenfileira falha transitória e conclui na tentativa seguinte", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const deps = depsOf(db);
|
||||||
|
const inner = deps.storage;
|
||||||
|
let failures = 1;
|
||||||
|
const flaky: ObjectStorage = {
|
||||||
|
put: (key, body) => inner.put(key, body),
|
||||||
|
delete: (key) => inner.delete(key),
|
||||||
|
list: (prefix) => inner.list(prefix),
|
||||||
|
async get(key) {
|
||||||
|
if (failures > 0 && key.includes("/tmp/")) {
|
||||||
|
failures -= 1;
|
||||||
|
throw new Error("transient");
|
||||||
|
}
|
||||||
|
return inner.get(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
deps.storage = flaky;
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
const model = await createModel(deps, ana, {
|
||||||
|
name: "Retry",
|
||||||
|
filename: "modelo.csv",
|
||||||
|
bytes: Buffer.from("Nome\n", "utf8"),
|
||||||
|
requiredColumns: ["Nome"],
|
||||||
|
});
|
||||||
|
const job = await createJob(deps, ana, model.id, "job-retry");
|
||||||
|
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||||
|
if (!("job" in source)) throw new Error("fonte");
|
||||||
|
const sourceId = source.job.sources[0]?.id ?? "";
|
||||||
|
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||||
|
await expect(startProcess(deps, ana, job.id, sourceId, "retry-1")).rejects.toMatchObject({ code: "PROCESS_FAILED" });
|
||||||
|
const done = await runProcess(deps, ana, job.id, sourceId).then(() => startProcess(deps, ana, job.id, sourceId, "retry-1"));
|
||||||
|
expect(done.status).toBe("completed");
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encerra por timeout sem nova tentativa infinita", async () => {
|
||||||
|
const previous = process.env.SPREADSHEET_JOB_TIMEOUT_MS;
|
||||||
|
process.env.SPREADSHEET_JOB_TIMEOUT_MS = "0";
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const deps = depsOf(db);
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
try {
|
||||||
|
const model = await createModel(deps, ana, {
|
||||||
|
name: "Timeout",
|
||||||
|
filename: "modelo.csv",
|
||||||
|
bytes: Buffer.from("Nome\n", "utf8"),
|
||||||
|
requiredColumns: ["Nome"],
|
||||||
|
});
|
||||||
|
const job = await createJob(deps, ana, model.id, "job-timeout");
|
||||||
|
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nAna\n", "utf8") });
|
||||||
|
if (!("job" in source)) throw new Error("fonte");
|
||||||
|
const sourceId = source.job.sources[0]?.id ?? "";
|
||||||
|
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||||
|
await expect(startProcess(deps, ana, job.id, sourceId, "timeout-1")).rejects.toMatchObject({ code: "TIMEOUT" });
|
||||||
|
const rows = await db.withService((query) =>
|
||||||
|
query.query<{ status: string }>(`select status from public.spreadsheet_jobs where id = $1`, [job.id]),
|
||||||
|
);
|
||||||
|
expect(rows[0]?.status).toBe("failed");
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.SPREADSHEET_JOB_TIMEOUT_MS;
|
||||||
|
else process.env.SPREADSHEET_JOB_TIMEOUT_MS = previous;
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa macro, CSV inválido e célula enorme", async () => {
|
||||||
|
expect(() => assertZipSafe(zipEntry("xl/vbaProject.bin"))).toThrow(/macros/i);
|
||||||
|
await expect(readSpreadsheet("dados.csv", Buffer.from([0xff, 0xfe, 0x41]))).rejects.toMatchObject({ code: "CSV_INVALID" });
|
||||||
|
const huge = Buffer.from(`Nome\n${"A".repeat(8001)}\n`, "utf8");
|
||||||
|
await expect(readSpreadsheet("grande.csv", huge)).rejects.toMatchObject({ code: "CELL_TOO_LARGE" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const real = process.env.SMOKE_REAL === "1";
|
||||||
|
|
||||||
|
describe.skipIf(!real)("smoke no ambiente real", () => {
|
||||||
|
it("autentica no Supabase, processa e baixa no bucket privado", () => {
|
||||||
|
throw new Error("SMOKE_REAL=1 foi definido, mas este teste ainda precisa do roteiro operacional.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smoke local", () => {
|
||||||
|
it("o caminho completo sem infraestrutura externa está em tests/integration/journey.test.ts", () => {
|
||||||
|
expect(real).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { tempTtlSeconds } from "@/config/retention";
|
||||||
|
import { collectProductionIssues } from "@/config/env";
|
||||||
|
import { shouldRequeue } from "@/domain/spreadsheet-adapter/failure-policy";
|
||||||
|
import { devAuthEnabled, verifyDevSession } from "@/server/auth/dev-session";
|
||||||
|
import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to";
|
||||||
|
import { buildSentryPayload } from "@/infra/observability/capture";
|
||||||
|
|
||||||
|
describe("política de piloto", () => {
|
||||||
|
it("recusa returnTo externo e aceita caminho interno", () => {
|
||||||
|
expect(safeReturnPath("https://evil.example/phish")).toBe("/adaptador");
|
||||||
|
expect(safeReturnPath("//evil.example")).toBe("/adaptador");
|
||||||
|
expect(safeReturnPath("/adaptador/operacao")).toBe("/adaptador/operacao");
|
||||||
|
const url = buildCentralLoginUrl("https://login.elo.example/entrar", "https://evil.example", "https://app.elo.example");
|
||||||
|
expect(url).not.toContain("evil.example");
|
||||||
|
expect(url).toContain("returnTo=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bloqueia autenticação de desenvolvimento em produção", () => {
|
||||||
|
const previous = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.ADAPTER_DEV_AUTH = "1";
|
||||||
|
process.env.ADAPTER_DEV_AUTH_SECRET = "segredo-local";
|
||||||
|
try {
|
||||||
|
expect(devAuthEnabled()).toBe(false);
|
||||||
|
expect(verifyDevSession("corpo.assinatura")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recusa produção sem Redis, com inline, com bucket público ou TTL inválido", () => {
|
||||||
|
const issues = collectProductionIssues({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
ADAPTER_DEV_AUTH: "1",
|
||||||
|
ADAPTER_JOB_MODE: "inline",
|
||||||
|
SUPABASE_STORAGE_BUCKET: "public",
|
||||||
|
SPREADSHEET_TEMP_TTL_SECONDS: "0",
|
||||||
|
});
|
||||||
|
const names = issues.map((issue) => issue.variable);
|
||||||
|
expect(names).toContain("REDIS_URL");
|
||||||
|
expect(names).toContain("ADAPTER_DEV_AUTH");
|
||||||
|
expect(names).toContain("ADAPTER_JOB_MODE");
|
||||||
|
expect(names).toContain("SUPABASE_STORAGE_BUCKET");
|
||||||
|
expect(names).toContain("SPREADSHEET_TEMP_TTL_SECONDS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exige TTL explícito em produção", () => {
|
||||||
|
const node = process.env.NODE_ENV;
|
||||||
|
const ttl = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
delete process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||||
|
try {
|
||||||
|
expect(() => tempTtlSeconds()).toThrow(/obrigatório/);
|
||||||
|
process.env.SPREADSHEET_TEMP_TTL_SECONDS = "abc";
|
||||||
|
expect(() => tempTtlSeconds()).toThrow(/inválido/);
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = node;
|
||||||
|
if (ttl === undefined) delete process.env.SPREADSHEET_TEMP_TTL_SECONDS;
|
||||||
|
else process.env.SPREADSHEET_TEMP_TTL_SECONDS = ttl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não reenfileira timeout e reenfileira falha transitória", () => {
|
||||||
|
expect(shouldRequeue("TIMEOUT", 504, 1)).toBe(false);
|
||||||
|
expect(shouldRequeue("PROCESS_FAILED", 500, 1)).toBe(true);
|
||||||
|
expect(shouldRequeue("PROCESS_FAILED", 500, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não envia célula sentinela ao payload do Sentry", () => {
|
||||||
|
const sentinel = "Ana Silva =CMD()";
|
||||||
|
const payload = JSON.stringify(
|
||||||
|
buildSentryPayload(new Error(`valor da célula ${sentinel}`), {
|
||||||
|
job_id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
error_code: "PROCESS_FAILED",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(payload).not.toContain(sentinel);
|
||||||
|
expect(payload).not.toContain("Ana Silva");
|
||||||
|
expect(payload).toContain("UNEXPECTED");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import ExcelJS from "exceljs";
|
||||||
|
import { readSpreadsheet } from "@/infra/spreadsheet/parse";
|
||||||
|
import { createModel, addSource, createJob, saveMappings, startProcess } from "@/server/spreadsheet-adapter/service";
|
||||||
|
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
||||||
|
|
||||||
|
describe("fixtures XLSX", () => {
|
||||||
|
it("lê abas, fórmula como texto, data, coluna vazia e caracteres especiais sem executar fórmula", async () => {
|
||||||
|
const book = new ExcelJS.Workbook();
|
||||||
|
const first = book.addWorksheet("Dados");
|
||||||
|
first.addRow(["Nome", "Valor", ""]);
|
||||||
|
first.getCell("A2").value = "José & Ação";
|
||||||
|
first.getCell("B2").value = new Date("2026-01-15T00:00:00.000Z");
|
||||||
|
first.mergeCells("A3:B3");
|
||||||
|
first.getCell("A3").value = "=1+1";
|
||||||
|
book.addWorksheet("Outra").addRow(["ignorar"]);
|
||||||
|
const bytes = Buffer.from(await book.xlsx.writeBuffer());
|
||||||
|
const parsed = await readSpreadsheet("livro.xlsx", bytes, { sheet: "Dados", headerRow: 1 });
|
||||||
|
expect(parsed.grid.sheetNames).toContain("Outra");
|
||||||
|
expect(parsed.grid.headers).toContain("Nome");
|
||||||
|
const flat = parsed.grid.rows.flat().join("|");
|
||||||
|
expect(flat).toContain("José & Ação");
|
||||||
|
expect(flat).not.toMatch(/^2$/);
|
||||||
|
expect(flat).toContain("=1+1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("não altera os bytes do modelo ao processar", async () => {
|
||||||
|
const db = await createReadyDb();
|
||||||
|
const deps = depsOf(db);
|
||||||
|
const ana = ctxOf("ana", "alpha", "owner", true);
|
||||||
|
const model = await createModel(deps, ana, {
|
||||||
|
name: "Livro",
|
||||||
|
filename: "modelo.csv",
|
||||||
|
bytes: Buffer.from("Nome\n", "utf8"),
|
||||||
|
requiredColumns: ["Nome"],
|
||||||
|
});
|
||||||
|
const before = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`);
|
||||||
|
const job = await createJob(deps, ana, model.id, "xlsx-model");
|
||||||
|
const source = await addSource(deps, ana, job.id, { filename: "fonte.csv", bytes: Buffer.from("Nome\nJosé\n", "utf8") });
|
||||||
|
if (!("job" in source)) throw new Error("fonte");
|
||||||
|
const sourceId = source.job.sources[0]?.id ?? "";
|
||||||
|
await saveMappings(deps, ana, job.id, sourceId, [{ sourceColumn: "Nome", modelColumn: "Nome" }]);
|
||||||
|
await startProcess(deps, ana, job.id, sourceId, "xlsx-proc");
|
||||||
|
const after = await deps.storage.get(`workspaces/${IDS.alpha}/models/${model.id}/original`);
|
||||||
|
expect(after.equals(before)).toBe(true);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user