File Storage
ProduçãoUpload e download de arquivos direto no S3, isolados por empresa cliente
Seu cliente envia um documento e ele vai direto do navegador para o armazenamento, sem passar pelo seu servidor. Você guarda só o registro de que o arquivo existe, de quem ele é e para que serve — e nunca paga banda para carregar byte alheio.
- Fintechs e financeiras que coletam documento de identidade, comprovante de renda e contrato
- Plataformas B2B que precisam separar arquivos por empresa cliente sem criar bucket por cliente
- Times que já usam outros building blocks e precisam anexar arquivo a um registro de negócio
- Operações que trocam mídia por WhatsApp e precisam materializar o arquivo em armazenamento próprio
- Serviço de upload contratado à parte, cobrado por GB armazenado
- Endpoint caseiro de upload que faz o arquivo passar pelo servidor da aplicação
- Bucket S3 por cliente, com política de acesso escrita e mantida à mão
- Tabela de metadados de arquivo reimplementada dentro de cada serviço
- CDN ou rede de distribuição de conteúdo para tráfego de massa
- Editor, conversor ou processador de imagem e vídeo
- Sistema de gestão documental com versionamento, fluxo de aprovação e retenção legal
- Armazenamento de dados estruturados consultáveis (isso é o building block data-store)
8 endpoints em 2 recursos.
Resumo executivo
O File Storage guarda os arquivos dos seus clientes sem que eles passem pelo seu servidor. Você pede uma URL de upload, entrega essa URL ao navegador ou ao aplicativo, e o arquivo vai direto para o armazenamento. Sua aplicação fica com o registro: qual arquivo é, de qual empresa, a qual negócio ele pertence e se o envio foi mesmo concluído.
Na prática ele resolve o anexo. Uma financeira que pede RG, comprovante de residência e comprovante de renda em toda proposta não precisa construir infraestrutura de upload, nem dimensionar servidor para aguentar quinhentos PDFs simultâneos, nem escrever a política de acesso que impede o parceiro A de baixar o documento do parceiro B.
Está no catálogo desde novembro de 2025, roda como serviço próprio em staging e em produção sobre S3 ou MinIO, e é irmão declarado do data-store: aquele guarda documentos JSON estruturados, este guarda bytes.
flowchart LR Cliente["Navegador ou app<br/>do seu cliente"] App["Sua aplicação"] FS["File Storage<br/>registra e assina"] S3["S3 ou MinIO<br/>os bytes moram aqui"] App -->|"1. pede a URL de upload"| FS FS -->|"2. uploadUrl + expiresAt"| App App -->|"3. entrega a URL"| Cliente Cliente ==>|"4. PUT dos bytes, sem passar pelo seu servidor"| S3 App -->|"5. confirm-upload"| FS FS -->|"6. HEAD no objeto"| S3
| Atributo | Valor |
|---|---|
| Identificador | file-storage |
| Categoria | Documentos |
| Escopo | Tenant (exige organizationId no token) |
| Porta (standalone) | 3005 conforme o registro de módulos (divergência conhecida em §13) |
| Path alias | @file-storage |
| Prefixo HTTP | /file-storage |
| Schema no banco | storage |
| Status | Produção desde 2025-11 |
| Depende de | PostgreSQL, Redis, armazenamento compatível com S3, IAM |
O problema
negócioO cenário. Todo produto B2B acaba precisando receber arquivo. Documento de identidade, comprovante, contrato assinado, foto de garantia, planilha de importação. Parece o pedaço trivial do sistema e é o pedaço que mais dá problema.
O desenho intuitivo — o arquivo chega no seu servidor e ele reenvia — é o que cobra a conta depois:
flowchart LR U["Usuário"] -->|"1. envia o arquivo"| API["Seu servidor de API"] API -->|"2. reenvia os mesmos bytes"| S["Armazenamento"] API -->|"3. responde"| U API --> Mem["Memória e conexão ocupadas<br/>pelo tráfego de arquivo"] Mem --> Pico["Pico de upload derruba rota<br/>que nada tem a ver com arquivo"] API --> Banda["Banda paga duas vezes:<br/>entrada e saída"]
O que trava hoje.
- O arquivo passa pelo seu servidor sem precisar. O upload chega no seu processo, ocupa memória, ocupa conexão, e o servidor que deveria responder chamada de API vira roteador de bytes. Um pico de envio simultâneo derruba o que não tem nada a ver com upload.
- A conta de saída aparece depois. Armazenar é barato. Devolver o arquivo ao usuário é o que custa: no S3 Standard, US$ 0,09 por GB de saída para a internet (tabela oficial da AWS, consultada em 2026-08-16). Um produto que entrega documento de volta ao cliente paga mais por banda do que por disco, e quase ninguém modela isso antes.
- Bucket por cliente não escala. É a saída intuitiva para isolar empresas e a que mais custa: política de acesso por bucket, limite de bucket por conta, e um provisionamento novo a cada cliente que entra.
- O controle de acesso vira código repetido. "Este usuário pode baixar este arquivo?" é uma pergunta que cada serviço responde de um jeito, e o terceiro esquece de perguntar.
- Ninguém sabe se o upload terminou. Sem confirmação, o banco fica cheio de registro de arquivo que nunca chegou, e o suporte descobre isso quando o cliente reclama que o documento sumiu.
O custo de não resolver. A parte mensurável é a fatura de saída, que só aparece quando o produto já está em escala.
A parte cara é a outra: cada serviço que precisa de anexo reimplementa upload, confirmação e autorização.
Atenção. Falha de controle de acesso lidera o OWASP Top 10 desde 2021 (OWASP Top 10:2021 — Broken Access Control). Arquivo de cliente exposto por URL adivinhável é um dos jeitos clássicos de chegar lá.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| O arquivo trafega pelo servidor da aplicação | Vai direto do cliente ao armazenamento, por URL pré-assinada |
| Um bucket por empresa cliente, com política própria | Um bucket, com o identificador da organização no início da chave |
| "Este usuário pode baixar?" respondido em cada serviço | FILES_READ no token, verificado no mesmo middleware de sempre |
| Registro de arquivo que nunca chegou | Confirmação que consulta o armazenamento antes de marcar como enviado |
| Fornecedor de armazenamento amarrado ao código | Qualquer armazenamento compatível com a API do S3, trocado por variável de ambiente |
Seu servidor não carrega byte. A aplicação só assina a URL. O upload e o download acontecem entre o cliente e o armazenamento, o que tira do seu dimensionamento o tráfego de arquivo inteiro.
O tenant está no caminho do objeto. A chave gravada é {organizationId}/{fileId}/{nome}. O identificador da empresa não é só uma coluna consultada — ele é o prefixo físico do objeto, o que torna auditoria, expurgo por cliente e política por prefixo operações diretas.
flowchart LR Org["organizationId<br/>vem do token do IAM"] --> K Fid["fileId<br/>gerado no registro"] --> K Nome["name<br/>informado na criação"] --> K K["s3Key = organizationId / fileId / nome"] --> Obj["Objeto no bucket único"] K --> Uso["Listar, expurgar e aplicar política<br/>por prefixo de um cliente inteiro"]
A confirmação é verificada, não declarada. confirm-upload faz HEAD no objeto antes de marcar uploaded: true. Se o arquivo não chegou, a chamada falha com 400. Também é daí que sai o tamanho real gravado, não o que o cliente declarou.
Metadado de negócio junto do arquivo. Cada arquivo carrega businessId e category, e a listagem filtra por eles. "Todos os comprovantes de renda da proposta 123" é uma chamada, não uma varredura.
Chamada entre serviços resolve sozinha o endereço certo. Um building block que faz o transporte dos bytes por conta própria recebe a URL assinada contra o endpoint interno do cluster; um cliente externo recebe a pública. A decisão é automática, baseada na identidade de quem chamou.
flowchart LR Dentro["Consumidor de dentro do cluster<br/>outro building block, pelo facade"] --> FS["File Storage"] Fora["Cliente externo<br/>navegador, app, integração"] --> FS FS -->|"URL assinada contra o endpoint interno"| Ei["Armazenamento, endereço do cluster"] FS -->|"URL assinada contra o endpoint público"| Ep["Armazenamento, endereço público"]
Casos de uso reais
negócioCaso 1 — Uma esteira de crédito recebe três documentos por proposta sem servidor de upload Cenário ilustrativo
Financeira de crédito consignado, cerca de 4 mil propostas por dia, três documentos obrigatórios em cada uma: identidade, comprovante de residência e comprovante de renda. Média de 2 MB por arquivo.
A primeira versão recebia o upload no próprio backend. Em horário de pico, o processo que respondia a API de proposta ficava ocupado carregando PDF, e a latência de tudo subia junto. O time chegou a subir réplica só para aguentar upload, o que é pagar servidor de aplicação para fazer trabalho de armazenamento.
O front pede POST /file-storage/api/v1/files com nome, tipo e a categoria (identity, address, income) e o businessId igual ao número da proposta. Recebe uploadUrl e envia o arquivo direto ao armazenamento com um PUT. Depois chama confirm-upload, que verifica no armazenamento se o objeto existe e grava o tamanho real. A esteira reage ao evento file-storage.file.uploaded para seguir para a próxima etapa.
sequenceDiagram
autonumber
participant Front as Front da proposta
participant FS as File Storage
participant S3 as Armazenamento
participant Esteira as Esteira de crédito
Front->>FS: POST /files com businessId da proposta e category
FS-->>Front: uploadUrl + expiresAt
Front->>S3: PUT dos bytes (identity, address, income)
Front->>FS: POST /files/{fileId}/confirm-upload
FS->>S3: HEAD no objeto
S3-->>FS: existe, com o tamanho real
FS-->>Front: uploaded true e sizeBytes medido
FS-)Esteira: evento file-storage.file.uploaded
Note over Front,S3: o backend da esteira nunca toca nos bytesO backend deixa de trafegar cerca de 24 GB por dia. As réplicas que existiam só para aguentar upload saem da conta, e a latência da API de proposta para de oscilar com o horário de envio.
Caso 2 — Documentos de trinta parceiros no mesmo armazenamento, sem bucket por parceiro Cenário ilustrativo
Plataforma de seguros que atende trinta corretoras. Cada corretora envia apólice e laudo dos próprios clientes, e nenhuma pode ver arquivo de outra.
A arquitetura anterior criava um bucket por corretora. Trinta buckets, trinta políticas de acesso escritas à mão, e um processo de onboarding que incluía "criar bucket e revisar política". Duas corretoras acabaram com política copiada errada, o que só foi descoberto em auditoria.
Um bucket. A chave do objeto começa com o organizationId da corretora, que vem do token assinado pelo IAM. Toda leitura de metadado filtra por esse identificador no banco, e a URL de download só é gerada para arquivo que pertence à organização do token. Entrar com uma corretora nova é criar a organização no IAM, e nada mais.
flowchart LR TA["Token da corretora A<br/>organizationId = A"] --> FS["File Storage"] TB["Token da corretora B<br/>organizationId = B"] --> FS FS -->|"toda busca é por id + partnerId"| B["Bucket único"] B --> KA["A / fileId / apolice.pdf"] B --> KB["B / fileId / laudo.pdf"] FS -->|"arquivo de outra organização"| E["404, indistinguível de inexistente"]
Onboarding de parceiro perde a etapa de infraestrutura. A política de acesso deixa de ser um artefato copiado por pessoa e passa a ser o mesmo middleware que já protege os outros building blocks.
Caso 3 — Mídia recebida por WhatsApp materializada no armazenamento próprio Cenário ilustrativo
Operação de atendimento que recebe foto e áudio de clientes por WhatsApp e precisa guardar essa mídia para transcrição e para o histórico.
A mídia vive no provedor de mensageria por tempo limitado. Copiá-la para armazenamento próprio exige que um processo de dentro do cluster baixe os bytes e os reenvie — e a URL pré-assinada normal aponta para o domínio público do armazenamento, que muitas vezes não é alcançável de dentro do próprio cluster. O resultado é conexão recusada e mídia que nunca materializa.
O building block WPP usa o facade, que autentica como o usuário sentinela de servidor. O roteador detecta esse identificador e pede a URL de upload assinada contra o endpoint interno, alcançável de dentro do cluster. Cliente externo continua recebendo a URL pública. Nenhuma configuração por ambiente, nenhuma bandeira manual por chamada.
sequenceDiagram
autonumber
participant WPP as Worker do WPP, dentro do cluster
participant FS as File Storage
participant S3 as MinIO ou S3
WPP->>FS: POST /files pelo facade, como usuário sentinela
Note right of FS: user.sub = 00000000-0000-0000-0000-000000000000
FS-->>WPP: uploadUrl assinada contra S3_ENDPOINT interno
WPP->>S3: PUT dos bytes baixados do provedor de mensageria
WPP->>FS: POST /files/{fileId}/confirm-upload
FS->>S3: HEAD no objeto
FS-->>WPP: uploaded trueMídia recebida por mensageria materializa no armazenamento da operação de forma determinística, e o download continua funcionando para consumidores externos.
Caso 4 — A taxa de saída é o que decide a conta de armazenamento Referência de mercado
O preço público de armazenamento e de egresso dos principais provedores, consultado em 2026-08-16.
| Provedor | Armazenamento | Saída para a internet | Fonte |
|---|---|---|---|
| Amazon S3 Standard | US$ 0,023 por GB/mês | US$ 0,09 por GB | AWS |
| Cloudflare R2 | US$ 0,015 por GB/mês | Gratuita | Cloudflare |
| Backblaze B2 | US$ 6,95 por TB/mês | Gratuita até três vezes o armazenamento médio | Backblaze |
Quem dimensiona custo olhando só o preço por GB armazenado erra a conta de um produto que devolve arquivo ao usuário. Guardar 1 TB no S3 custa cerca de US$ 23 por mês; devolver esse mesmo 1 TB ao usuário uma vez custa cerca de US$ 90 — quatro vezes mais que guardá-lo. A AWS isenta os primeiros 100 GB de saída por mês, agregados entre serviços e regiões, o que esconde o problema exatamente na fase em que o produto ainda é pequeno.
| 1 TB no S3 Standard | Custo aproximado |
|---|---|
| Guardar por um mês | US$ 23 |
| Devolver ao usuário uma vez | US$ 90 |
O File Storage não amarra o fornecedor: ele fala a API do S3 e o destino é configuração (S3_ENDPOINT, S3_BUCKET, credenciais). O mesmo código roda sobre MinIO no desenvolvimento local, sobre S3 ou sobre qualquer provedor compatível em produção. A escolha de onde os bytes moram — e, com ela, a exposição à taxa de saída — fica com quem paga a conta.
flowchart LR Cfg["Configuração<br/>S3_ENDPOINT · S3_BUCKET · credenciais"] --> FS["File Storage<br/>fala a API do S3"] FS --> A["Amazon S3<br/>saída a US$ 0,09 por GB"] FS --> R["Cloudflare R2<br/>saída gratuita"] FS --> B["Backblaze B2<br/>franquia de 3x o armazenado"] FS --> M["MinIO<br/>desenvolvimento local"]
A decisão de custo de egresso deixa de ser uma consequência da arquitetura e vira uma escolha explícita, revisável quando o volume mudar.
Mercado e diferenciais
negócioPanorama
O armazenamento de objetos virou commodity, e a disputa migrou para dois lugares. O primeiro é o preço de saída: a Cloudflare construiu o R2 em cima de "zero egresso" e a Backblaze oferece franquia generosa, enquanto o S3 e o Supabase Storage cobram na faixa de US$ 0,09 por GB. O segundo é a camada de aplicação: serviços como o Uploadthing não vendem disco, vendem a experiência de subir arquivo sem escrever infraestrutura.
flowchart TD M["Mercado de armazenamento de objetos"] --> P1["Disputa 1 — preço de saída"] M --> P2["Disputa 2 — camada de aplicação"] P1 --> R2["Cloudflare R2 — zero egresso"] P1 --> BB["Backblaze B2 — franquia generosa"] P1 --> S3P["Amazon S3 e Supabase — cerca de US$ 0,09 por GB"] P2 --> UT["Uploadthing — upload pronto para um app"] P2 --> FS["Catalisa File Storage — upload de uma plataforma multi-tenant"]
O File Storage está no segundo grupo, com um recorte diferente: ele não é a camada de upload de um app, é a camada de upload de uma plataforma multi-tenant. A pergunta que ele responde não é "como faço upload rápido", é "como faço upload de trinta empresas clientes no mesmo armazenamento sem misturar nada".
Tabela comparativa
| Critério | Catalisa File Storage | Amazon S3 | Cloudflare R2 | Backblaze B2 | Uploadthing |
|---|---|---|---|---|---|
| O que entrega | Camada de aplicação sobre armazenamento | Armazenamento cru | Armazenamento cru | Armazenamento cru | Camada de aplicação |
| Isolamento por empresa cliente | Pronto, pelo token e pelo prefixo da chave | Você escreve as políticas | Você escreve as políticas | Você escreve as políticas | Problema da sua aplicação |
| Metadado de negócio | businessId e category, com filtro na listagem | Só tags de objeto | Só metadados de objeto | Só metadados de objeto | Limitado |
| Confirmação de upload verificada | Sim, com HEAD no objeto | Você implementa | Você implementa | Você implementa | Sim |
| Preço de armazenamento | Herda o do provedor escolhido | US$ 0,023/GB/mês | US$ 0,015/GB/mês | US$ 6,95/TB/mês | Incluso no plano |
| Preço de saída | Herda o do provedor escolhido | US$ 0,09/GB | Gratuito | Gratuito até 3x o armazenado | Não discriminado |
| Troca de fornecedor | Variável de ambiente | — | — | — | Não se aplica |
| Integração com identidade e permissão | Mesma do catálogo inteiro | IAM da AWS | Tokens da Cloudflare | Chaves de aplicação | Própria |
Preços consultados nas páginas oficiais em 2026-08-16. Confira a tabela do fornecedor na data da sua análise.
Nossos diferenciais
- O tenant está no caminho do objeto, não só na consulta. A chave é
{organizationId}/{fileId}/{nome}. Isso muda o que é possível operacionalmente: listar tudo de um cliente, expurgar um cliente inteiro ou aplicar política por prefixo passam a ser operações de armazenamento, não varreduras de banco. - A escolha do fornecedor continua com o cliente. Falamos a API do S3 e não presumimos AWS. Quem tem tráfego de saída pesado pode apontar para o R2 e zerar essa linha da fatura sem trocar uma linha de código — a economia do produto muda, a integração não.
- A URL certa para quem chama, sem configuração. Consumidor de dentro do cluster recebe URL assinada contra o endpoint interno; cliente externo recebe a pública. A decisão vem da identidade de quem chamou, é determinística e não depende de variável por ambiente. Quem já depurou "funciona local, falha no cluster" sabe o tamanho disso.
- Vem com o resto da plataforma. O mesmo token, as mesmas permissões, os mesmos eventos. Um arquivo enviado aqui dispara
file-storage.file.uploaded, que o Webhooks Engine entrega ao sistema do cliente e o Audit Trail registra.
Quando escolher o concorrente
Há quatro situações em que a resposta honesta é "não use o File Storage":
| Se o seu caso é | Escolha | Por quê |
|---|---|---|
| Precisa apenas de armazenamento e já tem camada de aplicação própria | S3, R2 ou B2 direto | Nós rodamos em cima deles e não somos mais baratos que eles |
| App de time único em TypeScript, com o componente de upload pronto em uma tarde | Uploadthing | Ele entrega isso melhor do que nós |
| Distribuição global de conteúdo com cache na borda | Uma CDN | Não é o que este building block faz |
| Gestão documental de verdade — versionamento, fluxo de aprovação, política de retenção legal | Um GED | Isso é um GED, e nós não somos um |
O File Storage ganha quando o problema é anexo de negócio dentro de uma plataforma multi-tenant, integrado ao resto do catálogo.
Modelo de cobrança e ROI
negócioUnidade de cobrança
GB armazenado por mês. É a unidade que o cliente entende e que aparece na fatura de qualquer provedor de armazenamento, o que torna a comparação honesta.
Atenção. A precificação está em definição — não há tabela publicada, e este documento não estima uma.
O que dispara custo
Três drivers: volume armazenado, número de uploads (operações de escrita são cobradas por milhão em todos os provedores) e transferência de saída. O terceiro é o que costuma surpreender.
flowchart LR V["Volume armazenado<br/>GB por mês"] --> C["Fatura"] U["Número de uploads<br/>operações de escrita"] --> C S["Transferência de saída<br/>GB devolvidos ao usuário"] --> C S --> Surpresa["O driver que costuma surpreender"]
Custo de referência dos provedores
Cenário nomeado: 500 GB armazenados, 50 mil uploads por mês e 1 TB de saída por mês, uma operação que recebe documento e o devolve para consulta.
| Provedor | Armazenamento (500 GB) | Saída (1 TB) | Observação | Fonte e data |
|---|---|---|---|---|
| Amazon S3 Standard | ~US$ 11,50/mês a US$ 0,023/GB | ~US$ 83/mês a US$ 0,09/GB, descontados os 100 GB gratuitos | A saída custa cerca de sete vezes o armazenamento neste cenário | aws.amazon.com/s3/pricing, 2026-08-16 |
| Cloudflare R2 | ~US$ 7,50/mês a US$ 0,015/GB | US$ 0 | Cobra US$ 4,50 por milhão de operações de escrita; 50 mil uploads ficam abaixo de US$ 1 | developers.cloudflare.com/r2/pricing, 2026-08-16 |
| Backblaze B2 | ~US$ 3,50/mês a US$ 6,95/TB | US$ 0 dentro da franquia de 3x o armazenamento médio (1,5 TB aqui) | Acima da franquia, US$ 0,01/GB | backblaze.com/cloud-storage/pricing, 2026-08-16 |
| Supabase Storage (Pro) | US$ 25/mês com 100 GB inclusos, depois US$ 0,0213/GB | US$ 0,09/GB acima de 250 GB inclusos | Preço de plataforma, não só de armazenamento | supabase.com/pricing, 2026-08-16 |
| Uploadthing | US$ 25/mês com 250 GB inclusos, US$ 0,08/GB acima | Não discriminado na tabela pública | Modelo de plano, não de infraestrutura | uploadthing.com/pricing, 2026-08-16 |
Valores em dólares, consultados nas páginas oficiais em 2026-08-16, sem impostos e sem descontos negociados. As somas são aritmética simples sobre esses preços, para ordem de grandeza em conversa comercial — não são proposta e não substituem a calculadora do fornecedor.
ROI
A conta tem três partes, e a segunda costuma ser a maior.
- A escolha de fornecedor que o building block permite. No cenário acima, a diferença entre apontar para o S3 e apontar para o R2 é da ordem de US$ 87 por mês em uma operação pequena, quase toda em saída. Como a troca é configuração, essa decisão pode ser revisada quando o volume mudar, em vez de virar dívida de arquitetura.
- A engenharia que não é gasta. Construir upload direto ao armazenamento com URL pré-assinada, confirmação verificada, isolamento por cliente e trilha de eventos é da ordem de duas a quatro semanas de trabalho — estimativa interna — por serviço que precisa de anexo. Numa plataforma com vários building blocks que anexam arquivo, esse trabalho acontece uma vez.
- O servidor que não é dimensionado. Retirar o tráfego de arquivo do processo de aplicação evita réplicas que existem só para aguentar pico de upload. No Caso 1, são cerca de 24 GB por dia que deixam de passar pelo backend.
Arquitetura
Camadas e caminho da requisição
Quatro camadas, e o corte que importa: o Postgres guarda só metadados, os bytes moram no armazenamento de objetos.
flowchart TD
HTTP["Requisição HTTP"] --> App
subgraph App["Hono app — basePath /file-storage"]
R1["/api/v1/files — filesRouter"]
R2["/health — sonda de saúde"]
MW["authMiddleware → requirePermission(FILES_*)<br/>→ requireOrganization → parse Zod → handleResult"]
PID["partnerId = user.organizationId — NUNCA vem do corpo"]
end
App -->|"ResultAsync<T, AppError>"| Svc
subgraph Svc["services/FileService"]
S1["monta a chave organizationId / fileId / nome"]
S2["pede a URL assinada · confirma com HEAD · publica eventos"]
end
Svc --> Repo["repositories/FileRepository<br/>(Prisma)"]
Svc --> S3S["shared/lib/S3Service<br/>assina URL de upload e de download (SigV4)"]
Repo --> PG["Postgres<br/>schema storage · tabela files<br/>(só metadados)"]
S3S --> OBJ["S3 · MinIO · qualquer armazenamento<br/>compatível com a API do S3<br/>(os bytes moram aqui)"]O byte nunca atravessa a aplicação
Este é o fluxo completo da URL pré-assinada, do registro à confirmação. Repare que só as setas 1, 2, 5 e 6 falam com o building block — as setas 3 e 4 são o cliente conversando direto com o armazenamento.
sequenceDiagram
autonumber
participant C as Cliente (navegador, app ou serviço)
participant FS as File Storage
participant DB as Postgres (schema storage)
participant S3 as S3 / MinIO
C->>FS: POST /file-storage/api/v1/files
FS->>S3: assina a URL de upload para a chave organizationId/fileId/nome
FS->>DB: cria a linha com uploaded = false
FS-->>C: 201 com data, uploadUrl e expiresAt
C->>S3: PUT na uploadUrl com os bytes e o Content-Type declarado
S3-->>C: 200
C->>FS: POST /file-storage/api/v1/files/{fileId}/confirm-upload
FS->>S3: HEAD no objeto
S3-->>FS: existe, com o contentLength real
FS->>DB: uploaded = true e sizeBytes medido
FS-->>C: 200 com o arquivo confirmado
C->>FS: GET /file-storage/api/v1/files/{fileId}/download-url
FS-->>C: downloadUrl e expiresAt
C->>S3: GET na downloadUrl
S3-->>C: os bytesDecisões não óbvias
- Duas rotas para a URL de upload, de propósito.
POST /filescria o registro e devolve a URL na mesma resposta, o que resolve o caso comum em uma chamada.GET /files/:id/upload-urlgera outra URL para um registro que já existe — é o caminho para reenviar depois que a primeira expirou, sem criar registro duplicado. - A confirmação faz
HEADno objeto em vez de confiar no cliente. Marcaruploaded: trueporque o cliente disse que enviou produz registro fantasma. OHEADtambém é a fonte do tamanho real:sizeBytesenviado na criação é declaração, o gravado na confirmação é medição. - Dois clientes S3 no processo, um interno e um público. As URLs pré-assinadas normalmente são assinadas contra o endpoint público (
S3_PUBLIC_ENDPOINT), porque quem as usa é um navegador. Mas consumidor de dentro do cluster costuma não alcançar o domínio público do próprio armazenamento, e a chamada morre em conexão recusada. Por isso o serviço mantém um cliente por endpoint e escolhe qual assina. - A escolha do endpoint é determinística e vem da identidade. Chamada que chega autenticada como o usuário sentinela
00000000-0000-0000-0000-000000000000— o identificador que o facade usa entre building blocks — recebe a URL interna para upload. Qualquer outro chamador recebe a pública. Não é bandeira por chamada nem variável por ambiente: mesma entrada, mesmo endpoint, sempre. O download é sempre assinado contra o endpoint público, porque quem consome download é externo.
flowchart TD
Req["Pedido de URL de upload<br/>POST /files ou GET /files/{id}/upload-url"] --> Q{"user.sub é o sentinela<br/>00000000-0000-0000-0000-000000000000?"}
Q -->|"sim — chamada entre building blocks pelo facade"| I["Assina com o cliente interno<br/>S3_ENDPOINT"]
Q -->|"não — qualquer outro chamador"| P["Assina com o cliente público<br/>S3_PUBLIC_ENDPOINT"]
D["Pedido de URL de download<br/>GET /files/{id}/download-url"] --> P2["Sempre o cliente público<br/>S3_PUBLIC_ENDPOINT"]- Exclusão lógica no banco, exclusão real no armazenamento. O
DELETEmarcadeletedAtna linha e só então apaga o objeto, e apenas se ele tiver sido enviado. A ordem preserva o registro para auditoria mesmo que a remoção no armazenamento falhe — o preço dessa escolha está em §15. - Lista de tipos MIME fechada. O
mimeTypeé validado contra um conjunto explícito de 23 valores. Bloquear na criação é mais barato que descobrir depois que alguém guardou um executável. - O bucket é garantido no boot. No modo standalone, o serviço verifica se o bucket existe e o cria se não existir. Falha vira aviso, não impede o boot — a operação é tentada de novo no primeiro uso.
Monolito vs. standalone
Em monolito, o app é montado sob /file-storage e outros building blocks o alcançam pelo facade local, que já chama com a marcação de servidor. Em standalone — o modo de produção — sobe como serviço próprio e o facade remoto chama por HTTP, autenticado como o sentinela.
| Monolito | Standalone (produção) | |
|---|---|---|
| Como sobe | Montado sob /file-storage no app único | Serviço próprio, na porta do registro de módulos |
| Como outro BB chama | Facade local, direto pelo container TypeDI | Facade remoto, por HTTP em MODULE_FILE_STORAGE_URL |
| Identidade da chamada entre BBs | Usuário sentinela | Usuário sentinela |
| Comportamento de negócio | Idêntico | Idêntico |
O comportamento de negócio é idêntico; o que muda é qual endpoint assina a URL de upload.
Conceitos e modelo de dados
Glossário
| Termo | Significa |
|---|---|
Arquivo (File) | O registro de metadados. O byte mora no armazenamento; aqui fica quem ele é, de quem é e se chegou |
partnerId | O organizationId do token. É o tenant, e é o primeiro segmento da chave no armazenamento |
s3Key | O caminho do objeto: {partnerId}/{fileId}/{nome}. Único na tabela |
uploaded | Falso até a confirmação verificar o objeto no armazenamento. Só arquivo confirmado gera URL de download |
businessId | Identificador livre do negócio ao qual o arquivo pertence (número da proposta, id do contrato). Filtrável na listagem |
category | Classificação do arquivo. Valores previstos: identity, address, income, contract, collateral, other |
| URL pré-assinada | URL temporária que carrega a autorização dentro dela. Vale por PRESIGNED_URL_EXPIRY segundos, padrão 3600 |
| Usuário sentinela | 00000000-0000-0000-0000-000000000000, a identidade das chamadas entre building blocks. Recebe URL de upload interna |
Modelo de dados
Schema storage no PostgreSQL. Uma única tabela.
| Modelo Prisma | Tabela | Propósito | Campos-chave |
|---|---|---|---|
File | storage.files | Metadados do arquivo e ponteiro para o objeto | partnerId, name, mimeType, sizeBytes, uploaded, businessId, category, s3Key (único), s3Bucket, deletedAt, createdBy, updatedBy |
erDiagram
ORGANIZATION ||--o{ FILE : "possui os arquivos de"
FILE {
uuid id PK
uuid partner_id FK "o organizationId do token — o tenant, indexado"
string name "vira o último segmento da chave"
string mime_type "um dos 23 valores aceitos"
bigint size_bytes "declarado na criação, medido na confirmação"
boolean uploaded "false até o HEAD confirmar o objeto, indexado"
string business_id "identificador livre de negócio, indexado"
string category "classificação, indexada"
string s3_key "único — partnerId/fileId/nome"
string s3_bucket "bucket onde o objeto foi gravado"
timestamp created_at
timestamp updated_at
timestamp deleted_at "exclusão lógica, indexado"
uuid created_by "userId do token"
uuid updated_by "userId do token"
}Há índice em partnerId, businessId, category, uploaded e deletedAt, e uma relação de chave estrangeira com Organization do IAM.
Tipos MIME aceitos
São 23 valores.
| Grupo | Valores |
|---|---|
| Imagem | image/png, image/jpeg, image/webp, image/gif, image/heic |
| Vídeo | video/mp4, video/3gpp, video/quicktime |
| Áudio | audio/aac, audio/mp4, audio/mpeg, audio/ogg, audio/webm, audio/wav |
| Documento | application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/zip |
| Genérico | application/octet-stream, text/plain, application/json |
Ciclo de vida de um arquivo
O modelo não tem um campo status: o estado é a combinação de uploaded e deletedAt. São três estados e quatro transições.
stateDiagram-v2
direction TB
registrado: uploaded = false — registrado
disponivel: uploaded = true — disponível
excluido: deletedAt preenchido — linha preservada
[*] --> registrado: POST /files cria a linha e assina a URL de upload
registrado --> registrado: GET /files/{id}/upload-url gera nova URL se a primeira expirou, sem criar outra linha
registrado --> disponivel: PUT na uploadUrl e POST /files/{id}/confirm-upload, com HEAD confirmando o objeto
disponivel --> disponivel: GET /files/{id}/download-url devolve URL temporária
registrado --> excluido: DELETE /files/{id} — não há objeto a apagar
disponivel --> excluido: DELETE /files/{id} marca deletedAt e apaga o objeto do armazenamento
excluido --> [*]
note right of registrado
Enquanto uploaded for falso,
GET /files/{id}/download-url devolve 400.
end note| Estado | uploaded | deletedAt | O que dá para fazer |
|---|---|---|---|
| Registrado | false | vazio | Pedir nova URL de upload, enviar os bytes, confirmar, excluir |
| Disponível | true | vazio | Gerar URL de download, listar, excluir |
| Excluído | qualquer | preenchido | Nada pela API — a linha some das buscas e responde 404 |
Referência da API
Prefixo: /file-storage. Em staging, a base é https://storage.bb.stg.catalisa.app — o domínio é storage, o prefixo de rota é /file-storage.
Todas as rotas exigem, nesta ordem: authMiddleware (JWT válido), requirePermission(...) e requireOrganization (token com organizationId; devolve 403 sem ele). O corpo aceita o formato direto ou o envelope JSON:API ({"data":{"attributes":{...}}}).
flowchart LR R["Requisição HTTP"] --> A["authMiddleware<br/>JWT válido, senão 401"] A --> P["requirePermission(FILES_*)<br/>senão 403"] P --> O["requireOrganization<br/>sem organizationId, 403"] O --> Z["parse Zod<br/>corpo direto ou envelope JSON API"] Z --> S["FileService<br/>ResultAsync"] S --> H["handleResult<br/>JSON de resposta"]
São 7 endpoints, todos em routes/files.router.ts, mais GET /health, declarado em app.ts.
Arquivos — /file-storage/api/v1/files
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
POST | /file-storage/api/v1/files | Registra o arquivo e devolve a URL de upload | FILES_CREATE |
GET | /file-storage/api/v1/files | Lista arquivos da organização, paginado e filtrável | FILES_READ |
GET | /file-storage/api/v1/files/:fileId | Metadados de um arquivo | FILES_READ |
POST | /file-storage/api/v1/files/:fileId/confirm-upload | Verifica o objeto e marca como enviado | FILES_CREATE |
GET | /file-storage/api/v1/files/:fileId/upload-url | Gera nova URL de upload para um registro existente | FILES_CREATE |
GET | /file-storage/api/v1/files/:fileId/download-url | Gera URL temporária de download | FILES_READ |
DELETE | /file-storage/api/v1/files/:fileId | Exclusão lógica no banco e remoção do objeto | FILES_DELETE |
Saúde
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
GET | /file-storage/health | Sonda de saúde do serviço | Pública |
Quem tem as permissões. O papel ADMIN tem as três (FILES_CREATE, FILES_READ, FILES_DELETE). O papel FILE_MANAGER tem as três. O papel VIEWER tem apenas FILES_READ. Como em todo building block, a organização é o teto.
Atenção ao ler documentação antiga: não existem as rotas
/files/:id/downloadnem/files/:id/confirm. Os nomes reais sãodownload-urleconfirm-upload, e nenhuma rota devolve os bytes — todas devolvem URL.
POST /file-storage/api/v1/files
Cria o registro e devolve a URL de upload na mesma resposta. Exige FILES_CREATE.
Request
{
"name": "comprovante-renda.pdf",
"mimeType": "application/pdf",
"sizeBytes": 1048576,
"businessId": "PROP-2026-000123",
"category": "income"
}{
"name": "comprovante-renda.pdf",
"mimeType": "application/pdf",
"sizeBytes": 1048576,
"businessId": "PROP-2026-000123",
"category": "income"
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
name | string (1–255) | Sim | Nome do arquivo. Vira o último segmento da chave no armazenamento |
mimeType | string | Sim | Precisa estar na lista de 23 tipos aceitos (§8). Fora dela, 400 |
sizeBytes | number inteiro | Não | Até 104.857.600 (100 MB). É declaração, substituída pela medição na confirmação |
businessId | string (até 100) | Não | Identificador do negócio ao qual o arquivo pertence |
category | string (até 50) | Não | Classificação. Valores previstos em §8 |
Resposta 201
{
"data": {
"type": "files",
"id": "7c1e2a44-9b3d-4f10-8a55-2e6b1c0d9f33",
"links": { "self": "/api/v1/files/7c1e2a44-9b3d-4f10-8a55-2e6b1c0d9f33" },
"attributes": {
"name": "comprovante-renda.pdf",
"mimeType": "application/pdf",
"sizeBytes": 1048576,
"uploaded": false,
"businessId": "PROP-2026-000123",
"category": "income",
"createdAt": "2026-08-16T12:00:00.000Z",
"createdBy": "b1000000-0000-0000-0000-000000000001"
}
},
"uploadUrl": "https://s3.bb.stg.catalisa.app/building-blocks/b0000000-…/7c1e2a44-…/comprovante-renda.pdf?X-Amz-Algorithm=…",
"expiresAt": "2026-08-16T13:00:00.000Z",
"links": { "self": "/api/v1/files/7c1e2a44-9b3d-4f10-8a55-2e6b1c0d9f33" }
}{
"data": {
"type": "files",
"id": "7c1e2a44-9b3d-4f10-8a55-2e6b1c0d9f33",
"links": { "self": "/api/v1/files/7c1e2a44-9b3d-4f10-8a55-2e6b1c0d9f33" },
"attributes": {
"name": "comprovante-renda.pdf",
"mimeType": "application/pdf",
"sizeBytes": 1048576,
"uploaded": false,
"businessId": "PROP-2026-000123",
"category": "income",
"createdAt": "2026-08-16T12:00:00.000Z",
"createdBy": "b1000000-0000-0000-0000-000000000001"
}
},
"uploadUrl": "https://s3.bb.stg.catalisa.app/building-blocks/b0000000-…/7c1e2a44-…/comprovante-renda.pdf?X-Amz-Algorithm=…",
"expiresAt": "2026-08-16T13:00:00.000Z",
"links": { "self": "/api/v1/files/7c1e2a44-9b3d-4f10-8a55-2e6b1c0d9f33" }
}O uploadUrl está fora de data, no topo da resposta. expiresAt marca o fim da validade — por padrão, uma hora depois da emissão. O links do topo repete o links de data.
Erros
| Status | Quando |
|---|---|
400 | mimeType fora da lista, nome vazio, sizeBytes acima de 100 MB |
401 | Token ausente ou inválido |
403 | Token sem organizationId, ou sem FILES_CREATE |
500 | Falha ao assinar a URL ou ao gravar o registro |
Enviar os bytes
Não é uma rota do building block. Você faz PUT direto na uploadUrl, com o mesmo Content-Type que declarou:
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @comprovante-renda.pdfcurl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @comprovante-renda.pdfO Content-Type faz parte da assinatura. Enviar um diferente do declarado na criação faz o armazenamento recusar com erro de assinatura.
POST /file-storage/api/v1/files/:fileId/confirm-upload
Verifica o objeto no armazenamento e marca o registro como enviado. Exige FILES_CREATE.
Não recebe corpo. O serviço faz HEAD no objeto: se ele não existe, a chamada falha e o registro continua com uploaded: false. Se existe, grava uploaded: true e substitui sizeBytes pelo tamanho medido, publicando file-storage.file.uploaded.
flowchart TD
C["POST /files/{fileId}/confirm-upload"] --> F{"o arquivo existe nesta organização?"}
F -->|"não"| E404["404 — inexistente, excluído ou de outra organização"]
F -->|"sim"| H["HEAD no objeto no armazenamento"]
H -->|"objeto ausente"| E400["400 — File has not been uploaded to storage<br/>o registro continua com uploaded false"]
H -->|"objeto presente"| U["uploaded = true e sizeBytes medido"]
U --> EV["publica file-storage.file.uploaded"]
EV --> OK["200 com o arquivo confirmado"]Resposta 200 — o arquivo com uploaded: true e o sizeBytes real.
Erros
| Status | Quando |
|---|---|
400 | File has not been uploaded to storage — o objeto não está lá. Reenvie os bytes antes de confirmar |
404 | Arquivo inexistente, excluído, ou de outra organização |
GET /file-storage/api/v1/files/:fileId/download-url
Gera uma URL temporária de download. Exige FILES_READ.
Resposta 200
{
"downloadUrl": "https://s3.bb.stg.catalisa.app/building-blocks/b0000000-…/7c1e2a44-…/comprovante-renda.pdf?X-Amz-Algorithm=…",
"expiresAt": "2026-08-16T13:00:00.000Z"
}{
"downloadUrl": "https://s3.bb.stg.catalisa.app/building-blocks/b0000000-…/7c1e2a44-…/comprovante-renda.pdf?X-Amz-Algorithm=…",
"expiresAt": "2026-08-16T13:00:00.000Z"
}Erros
| Status | Quando |
|---|---|
400 | File has not been uploaded yet — não se gera download de arquivo não confirmado |
404 | Arquivo inexistente, excluído, ou de outra organização |
A URL vale até expiresAt e quem a possui consegue baixar o arquivo, sem apresentar token. Trate-a como credencial de curta duração — §14 detalha.
GET /file-storage/api/v1/files
Lista os arquivos da organização, paginado. Exige FILES_READ.
| Parâmetro | Descrição |
|---|---|
page[number], page[size] | Paginação padrão da plataforma |
filter[category] | Filtra por categoria |
filter[businessId] | Filtra por identificador de negócio |
filter[uploaded] | true ou false. Útil para achar upload abandonado |
Ordena por createdAt decrescente. Devolve data, meta de paginação e links. Arquivos excluídos logicamente não aparecem.
DELETE /file-storage/api/v1/files/:fileId
Marca deletedAt no registro e, se o arquivo tiver sido enviado, apaga o objeto do armazenamento. Publica file-storage.file.deleted. Exige FILES_DELETE. Responde 204 sem corpo.
Início rápido
Do zero a um arquivo enviado e baixável. Os comandos usam staging; substitua pelo seu ambiente. Não executados nesta revisão da documentação — confira a resposta no seu ambiente.
São seis passos, e o quarto é o único que não existe em nenhuma outra API de upload:
flowchart LR P1["1. Autenticar<br/>no IAM"] --> P2["2. Registrar o arquivo<br/>e pegar a uploadUrl"] P2 --> P3["3. PUT dos bytes<br/>direto no armazenamento"] P3 --> P4["4. confirm-upload<br/>HEAD verifica e mede"] P4 --> P5["5. download-url<br/>e baixar"] P5 --> P6["6. Conferir o isolamento<br/>com token de outra organização"]
1. Autenticar no IAM
TOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
BASE=https://storage.bb.stg.catalisa.appTOKEN=$(curl -s -X POST https://iam.bb.stg.catalisa.app/iam/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@catalisa.app",
"password": "root123456",
"organizationId": "b0000000-0000-0000-0000-000000000001"
}' | jq -r .accessToken)
BASE=https://storage.bb.stg.catalisa.app2. Registrar o arquivo e pegar a URL de upload
RESP=$(curl -s -X POST "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"comprovante-renda.pdf","mimeType":"application/pdf",
"businessId":"PROP-2026-000123","category":"income"}')
FILE_ID=$(echo "$RESP" | jq -r '.data.id')
UPLOAD_URL=$(echo "$RESP" | jq -r '.uploadUrl')
echo "$RESP" | jq '{id: .data.id, uploaded: .data.attributes.uploaded, expiresAt}'RESP=$(curl -s -X POST "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"comprovante-renda.pdf","mimeType":"application/pdf",
"businessId":"PROP-2026-000123","category":"income"}')
FILE_ID=$(echo "$RESP" | jq -r '.data.id')
UPLOAD_URL=$(echo "$RESP" | jq -r '.uploadUrl')
echo "$RESP" | jq '{id: .data.id, uploaded: .data.attributes.uploaded, expiresAt}'{ "id": "7c1e2a44-…", "uploaded": false, "expiresAt": "2026-08-16T13:00:00.000Z" }{ "id": "7c1e2a44-…", "uploaded": false, "expiresAt": "2026-08-16T13:00:00.000Z" }3. Enviar os bytes direto ao armazenamento
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @comprovante-renda.pdfcurl -s -o /dev/null -w "%{http_code}\n" -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
--data-binary @comprovante-renda.pdfDevolve 200. Repare que esta chamada não passa pelo building block — os bytes vão do seu terminal ao armazenamento.
4. Confirmar o upload
curl -s -X POST "$BASE/file-storage/api/v1/files/$FILE_ID/confirm-upload" \
-H "Authorization: Bearer $TOKEN" | jq '.data.attributes | {uploaded, sizeBytes}'curl -s -X POST "$BASE/file-storage/api/v1/files/$FILE_ID/confirm-upload" \
-H "Authorization: Bearer $TOKEN" | jq '.data.attributes | {uploaded, sizeBytes}'{ "uploaded": true, "sizeBytes": 1048576 }{ "uploaded": true, "sizeBytes": 1048576 }O sizeBytes agora é o tamanho medido no armazenamento, não o que você declarou.
5. Gerar a URL de download e baixar
DOWNLOAD_URL=$(curl -s "$BASE/file-storage/api/v1/files/$FILE_ID/download-url" \
-H "Authorization: Bearer $TOKEN" | jq -r '.downloadUrl')
curl -s "$DOWNLOAD_URL" -o baixado.pdf && ls -l baixado.pdfDOWNLOAD_URL=$(curl -s "$BASE/file-storage/api/v1/files/$FILE_ID/download-url" \
-H "Authorization: Bearer $TOKEN" | jq -r '.downloadUrl')
curl -s "$DOWNLOAD_URL" -o baixado.pdf && ls -l baixado.pdfO arquivo aparece no disco com o mesmo tamanho que a confirmação mediu. A downloadUrl não pede token — ela já carrega a autorização dentro dela (§14).
6. Confirmar que o isolamento é real
curl -s -o /dev/null -w "%{http_code}\n" \
"$BASE/file-storage/api/v1/files/$FILE_ID" \
-H "Authorization: Bearer $TOKEN_DE_OUTRA_ORGANIZACAO"curl -s -o /dev/null -w "%{http_code}\n" \
"$BASE/file-storage/api/v1/files/$FILE_ID" \
-H "Authorization: Bearer $TOKEN_DE_OUTRA_ORGANIZACAO"Devolve 404, não 403 — de propósito. Arquivo de outra organização é indistinguível de arquivo inexistente, o que impede descobrir a existência de um identificador por tentativa.
Credenciais de staging, publicadas em AMBIENTES.md. Nunca use credencial de produção em documentação ou script de exemplo.
Receitas
Upload a partir do navegador, sem passar pelo seu backend
Objetivo. Que o arquivo vá do computador do usuário para o armazenamento sem tocar no seu servidor.
sequenceDiagram
autonumber
participant N as Navegador
participant B as Seu backend
participant FS as File Storage
participant S3 as Armazenamento
N->>B: POST /api/anexos
B->>FS: POST /files com o token do IAM
FS-->>B: uploadUrl e expiresAt
B-->>N: só a uploadUrl — o token nunca sai do backend
N->>S3: PUT com o arquivo e o Content-Type declarado
N->>B: POST /api/anexos/{id}/confirmar
B->>FS: POST /files/{fileId}/confirm-upload
FS->>S3: HEAD no objeto
FS-->>B: uploaded true1. Seu backend pede a URL. O token do IAM fica no servidor; o navegador recebe só a URL assinada.
const { data, uploadUrl } = await fetch('/api/anexos', { method: 'POST' }).then(r => r.json())const { data, uploadUrl } = await fetch('/api/anexos', { method: 'POST' }).then(r => r.json())2. O navegador envia direto ao armazenamento. Esta chamada não passa pelo seu backend nem pelo building block.
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
})await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
})O armazenamento responde 200 quando aceita os bytes.
3. Seu backend confirma. É a chamada que faz o HEAD e marca o registro como enviado.
await fetch(`/api/anexos/${data.id}/confirmar`, { method: 'POST' })await fetch(`/api/anexos/${data.id}/confirmar`, { method: 'POST' })Armadilhas.
- O
Content-TypedoPUTprecisa ser exatamente omimeTypedeclarado na criação. Ele entra na assinatura; qualquer diferença vira erro de assinatura, e a mensagem do armazenamento não é óbvia. - Não use
fetchcomFormDataaqui. A URL pré-assinada espera o corpo cru, nãomultipart/form-data. - Nunca envie o token do IAM ao navegador para ele chamar o building block direto. O desenho correto é o do exemplo: o backend assina, o navegador só transporta bytes.
- A URL expira em uma hora por padrão. Se o usuário abandonar a tela e voltar depois, peça outra em
GET /files/:id/upload-urlem vez de criar um registro novo.
Anexar arquivos a um registro de negócio e recuperá-los depois
Objetivo. Guardar os três documentos de uma proposta e listar só os dela.
flowchart LR P["Proposta PROP-2026-000123"] --> D1["identity.pdf<br/>category = identity"] P --> D2["address.pdf<br/>category = address"] P --> D3["income.pdf<br/>category = income"] D1 --> B["businessId = PROP-2026-000123"] D2 --> B D3 --> B B --> L["GET /files com filter de businessId<br/>e filter de uploaded"]
1. Criar os três registros com o mesmo businessId e categorias diferentes.
for CAT in identity address income; do
curl -s -X POST "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"$CAT.pdf\",\"mimeType\":\"application/pdf\",
\"businessId\":\"PROP-2026-000123\",\"category\":\"$CAT\"}"
donefor CAT in identity address income; do
curl -s -X POST "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"$CAT.pdf\",\"mimeType\":\"application/pdf\",
\"businessId\":\"PROP-2026-000123\",\"category\":\"$CAT\"}"
doneCada iteração devolve um 201 com o seu próprio uploadUrl. Envie os bytes de cada um e confirme, como no §10.
2. Recuperar todos os da proposta que já chegaram.
curl -s -G "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filter[businessId]=PROP-2026-000123' \
--data-urlencode 'filter[uploaded]=true' | jq '.data[].attributes | {name, category}'curl -s -G "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filter[businessId]=PROP-2026-000123' \
--data-urlencode 'filter[uploaded]=true' | jq '.data[].attributes | {name, category}'{ "name": "identity.pdf", "category": "identity" }
{ "name": "address.pdf", "category": "address" }
{ "name": "income.pdf", "category": "income" }{ "name": "identity.pdf", "category": "identity" }
{ "name": "address.pdf", "category": "address" }
{ "name": "income.pdf", "category": "income" }Armadilhas.
businessIdé texto livre e não é validado contra nada. Um erro de digitação cria um grupo órfão que ninguém vai encontrar — gere esse valor no código, nunca à mão.categorytambém é texto livre no schema da rota. Os seis valores de §8 são convenção da plataforma, não restrição do código.- Filtre por
filter[uploaded]=trueao montar tela para o usuário. Sem isso, entram registros de upload que nunca terminou.
Encontrar e limpar uploads abandonados
Objetivo. Achar registros criados que nunca receberam bytes.
1. Listar os registros que ficaram sem bytes.
curl -s -G "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filter[uploaded]=false' \
--data-urlencode 'page[size]=100' | jq '.data[] | {id, name, createdAt: .attributes.createdAt}'curl -s -G "$BASE/file-storage/api/v1/files" \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filter[uploaded]=false' \
--data-urlencode 'page[size]=100' | jq '.data[] | {id, name, createdAt: .attributes.createdAt}'{ "id": "7c1e2a44-…", "name": "comprovante-renda.pdf", "createdAt": "2026-08-14T09:12:00.000Z" }{ "id": "7c1e2a44-…", "name": "comprovante-renda.pdf", "createdAt": "2026-08-14T09:12:00.000Z" }2. Excluir os que já passaram do prazo aceitável.
curl -s -X DELETE "$BASE/file-storage/api/v1/files/$FILE_ID" \
-H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"curl -s -X DELETE "$BASE/file-storage/api/v1/files/$FILE_ID" \
-H "Authorization: Bearer $TOKEN" -o /dev/null -w "%{http_code}\n"Devolve 204, sem corpo.
Armadilhas.
- Não existe limpeza automática. Registro com
uploaded: falsefica lá para sempre até alguém apagar — vale uma rotina periódica. - Excluir um registro não enviado não tenta apagar objeto no armazenamento, porque não há objeto. É seguro.
- A exclusão é lógica: a linha continua no banco com
deletedAt. Se o seu requisito é eliminação definitiva, é preciso expurgo à parte.
Consumir o File Storage de outro building block
Objetivo. Um serviço seu que já tem os bytes em mãos quer guardá-los, de dentro do cluster.
Use o facade (FileStorageFacadeToken) em vez de chamar a API direto. Ele autentica como o usuário sentinela, e o roteador detecta essa identidade e devolve a URL de upload assinada contra o endpoint interno — o único alcançável de dentro do cluster.
flowchart TD
BB["Seu building block, dentro do cluster"] --> Esc{"Como você chama?"}
Esc -->|"pelo facade — usuário sentinela"| OK["uploadUrl contra S3_ENDPOINT<br/>PUT funciona de dentro do cluster"]
Esc -->|"pela API, com token de usuário comum"| ERR["uploadUrl contra S3_PUBLIC_ENDPOINT<br/>PUT falha com conexão recusada ou timeout"]Armadilhas.
- Se você chamar a API com um token de usuário comum a partir de um processo de dentro do cluster, vai receber a URL pública e o
PUTpode falhar com conexão recusada ou timeout. Esse é o modo de falha exato descrito em file-storage-internal-presigned-urls.md. - A URL de download é sempre assinada contra o endpoint público, mesmo para chamador interno. Se um dia um consumidor de dentro do cluster precisar baixar, isso exige mudança no serviço.
- O endpoint interno precisa ser alcançável pelos workers (
S3_ENDPOINT, por exemplohttp://minio:9000), e o público precisa ser o domínio externo. Errar essa dupla é a causa mais comum de "funciona local, falha no cluster".
Integração com outros building blocks
| Building block | Como se relaciona | Obrigatório |
|---|---|---|
| IAM | Emite o token com organizationId e as permissões FILES_*. É de onde vem o tenant | Sim |
| Data Store | O irmão: guarda o documento JSON com os campos de negócio e o fileId que aponta para o arquivo | Não |
| E-Signature | Guarda aqui o documento a ser assinado e o resultado assinado | Não |
| WPP | Materializa aqui a mídia recebida por WhatsApp, usando a URL de upload interna | Não |
| Webhooks Engine | Entrega os eventos file-storage.* a sistemas externos do cliente | Não |
| Audit Trail | Registra quem enviou, leu e apagou arquivo | Não |
Eventos publicados. file-storage.file.created, file-storage.file.uploaded, file-storage.file.deleted. Todos carregam organizationId e userId nos metadados.
Documentação antiga cita os eventos como
storage.file.*. Os nomes reais começam comfile-storage.. Assinatura de webhook feita com o prefixo antigo não recebe nada.
flowchart TD U["Usuário"] -->|"pede a URL"| IAM["IAM — token"] IAM -->|"organizationId do token"| FS["File Storage"] FS -->|"assina a URL"| S3["S3 / MinIO"] U ==>|"PUT dos bytes"| S3 FS -->|"guarda o fileId junto do dado de negócio"| DS["Data Store — documento"] FS --> ES["E-Signature · WPP"] FS -->|"eventos file-storage.*"| WH["Webhooks Engine · Audit Trail"]
O par com o Data Store é o desenho recomendado: o binário fica no armazenamento pelo file-storage e o documento JSON guarda o fileId junto dos campos de negócio. Assim a consulta por conteúdo acontece sobre metadado estruturado, e o blob não passa pelo banco. Esse é o argumento da plataforma inteira em uma imagem — as peças se encaixam sem cola escrita pelo cliente.
Configuração e operação
Variáveis de ambiente
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
S3_ACCESS_KEY_ID | Credencial de acesso ao armazenamento. O serviço não instancia o cliente S3 sem ela | Sim | — |
S3_SECRET_ACCESS_KEY | Segredo da credencial. Mesma regra acima | Sim | — |
S3_BUCKET | Nome do bucket | Não | files |
S3_REGION | Região usada na assinatura | Não | us-east-1 |
S3_ENDPOINT | Endpoint interno, alcançável de dentro do cluster (ex.: http://minio:9000) | Não | — |
S3_PUBLIC_ENDPOINT | Endpoint público, usado para assinar URL destinada a cliente externo. Sem ele, tudo é assinado contra S3_ENDPOINT | Não | — |
S3_FORCE_PATH_STYLE | Bucket no caminho em vez de no subdomínio. Necessário para MinIO | Não | true |
PRESIGNED_URL_EXPIRY | Validade das URLs pré-assinadas, em segundos | Não | 3600 |
DATABASE_URL | PostgreSQL. O schema storage vive nele | Sim | — |
REDIS_URL | Redis. Usado só pelos contadores do rate limit global | Sim | — |
JWT_SECRET | Segredo compartilhado para verificar o token do IAM (mínimo 44 caracteres) | Sim | — |
RATE_LIMIT_ENABLED | Liga o rate limit global por IP | Não | true |
MODULE_FILE_STORAGE_URL | URL do serviço, usada pelo facade remoto em standalone | Em standalone | — |
PORT | Porta no modo standalone | Não | 3000 |
Divergência de porta conhecida. O registro de módulos (
src/shared/registry/types.ts) declarafile-storage: 3005, e é o valor autoritativo. Odocker-compose.yamlde desenvolvimento publica3004:3000, e READMEs antigos citam3004. Dentro do cluster o serviço escuta sempre em3000, então a divergência só afeta desenvolvimento local. Confira a porta publicada antes de apontar um cliente local.
Dependências de infraestrutura
flowchart LR FS["File Storage"] --> PG["PostgreSQL<br/>schema storage, tabela files"] FS --> OBJ["Armazenamento compatível com S3<br/>MinIO local, S3 ou equivalente em produção"] FS --> RD["Redis<br/>contadores do rate limit — falha aberta"] FS --> IAM["IAM<br/>verificação do token e permissões"]
| Dependência | Para quê |
|---|---|
| PostgreSQL | Schema storage, tabela files (só metadados) |
| Armazenamento compatível com S3 | Onde os bytes moram. MinIO no desenvolvimento local, S3 ou equivalente em produção |
| Redis | Contadores do rate limit global aplicado a todas as rotas. Falha aberta: se o Redis estiver fora, a requisição passa e um aviso vai para o log |
| IAM | Verificação do token e resolução de permissões |
Limites e quotas
| Limite | Valor | Onde é aplicado |
|---|---|---|
| Tamanho do corpo da requisição | 1 MiB | applyCommonMiddleware. Não limita o arquivo: os bytes vão direto ao armazenamento, sem passar por aqui |
| Rate limit global | 10.000 requisições por IP a cada 60 segundos | rateLimitMiddleware, 429 acima disso |
sizeBytes declarado | 100 MB (104.857.600) | Schema Zod da rota. É declaração, não é imposta ao armazenamento (§15) |
| Nome do arquivo | 255 caracteres | 400 acima disso |
businessId | 100 caracteres | 400 acima disso |
category | 50 caracteres | 400 acima disso |
| Tipos MIME aceitos | 23 valores (§8) | 400 fora da lista |
| Validade da URL pré-assinada | 3600 segundos (padrão) | PRESIGNED_URL_EXPIRY |
Não há quota de armazenamento por tenant. Um cliente pode enviar até onde o bucket aguentar.
Catálogo de erros
| Status | Código | Significa | O que fazer |
|---|---|---|---|
400 | VALIDATION | mimeType fora da lista, campo fora do tamanho, corpo reprovado no Zod | Confira contra §9 |
400 | VALIDATION | File has not been uploaded to storage | Os bytes não chegaram. Reenvie na uploadUrl antes de confirmar |
400 | VALIDATION | File has not been uploaded yet | Confirme o upload antes de pedir URL de download |
401 | UNAUTHORIZED | Token ausente, inválido ou expirado | Renove o token no IAM |
403 | — | Token sem organizationId | Autentique informando a organização |
403 | FORBIDDEN | Falta FILES_CREATE, FILES_READ ou FILES_DELETE | Confira as permissões no token |
404 | NOT_FOUND | Arquivo inexistente, excluído, ou de outra organização | Confira o id. A indistinção é proposital |
429 | too_many_requests | Rate limit global por IP estourado | Aplique recuo exponencial; o cabeçalho Retry-After diz quanto esperar |
500 | INTERNAL | Falha ao assinar URL, ao consultar o armazenamento ou ao gravar no banco | Verifique credenciais S3, alcance do endpoint e o banco |
Atenção. Erros de assinatura (SignatureDoesNotMatch) e de expiração vêm do armazenamento, não do building block, e aparecem como resposta do PUT ou do GET na URL pré-assinada. A causa mais comum é Content-Type diferente do declarado; a segunda é relógio fora de sincronia.
Observabilidade
GET /file-storage/healthdevolve a versão do build e o nome do módulo. É a sonda usada pelo healthcheck do container.- No boot em standalone, o serviço registra em log se o bucket já existia ou foi criado. Falha vira aviso e não impede o boot.
- Os eventos
file-storage.*carregamuserIdeorganizationId, o que permite reconstruir o histórico de quem enviou e apagou o quê pelo Audit Trail. - Métrica operacional útil sem custo: a contagem de registros com
uploaded: falsemais velhos que algumas horas. É o indicador direto de upload que está falhando no caminho entre cliente e armazenamento.
Segurança e compliance
Isolamento entre tenants
O partnerId é sempre user.organizationId, extraído do token assinado pelo IAM. Nenhuma rota lê o identificador da organização do corpo ou da query da requisição. Sobre isso:
- Autenticação —
authMiddlewareverifica a assinatura do token. - Autorização —
requirePermission(FILES_CREATE | FILES_READ | FILES_DELETE). - Contexto obrigatório —
requireOrganizationdevolve403para token semorganizationId. - Escopo na consulta — toda busca de arquivo é
findFirst({ id, partnerId, deletedAt: null }). Não existe busca por id sem o tenant junto, o que faz arquivo de outra organização responder404, indistinguível de inexistente. - Escopo no caminho do objeto — a chave é
{organizationId}/{fileId}/{nome}. O tenant é o prefixo físico, o que permite política por prefixo no armazenamento como camada extra, se o provedor oferecer.
flowchart TD T["Token assinado pelo IAM"] --> C1["1. authMiddleware verifica a assinatura"] C1 -->|"token inválido"| E401["401"] C1 --> C2["2. requirePermission — FILES_CREATE, FILES_READ ou FILES_DELETE"] C2 -->|"sem a permissão"| E403["403"] C2 --> C3["3. requireOrganization exige organizationId no token"] C3 -->|"token sem organização"| E403 C3 --> C4["4. findFirst com id + partnerId + deletedAt null"] C4 -->|"não pertence a esta organização"| E404["404 — indistinguível de inexistente"] C4 --> C5["5. chave organizationId / fileId / nome no armazenamento"]
URLs pré-assinadas — o que elas são e o risco que carregam
Uma URL pré-assinada carrega a autorização dentro dela. A AWS é direta sobre o que isso significa: "presigned URLs are bearer tokens that grant access to those who possess them. As such, we recommend that you protect them appropriately" (AWS — Download and upload objects with presigned URLs). Quem tem a URL baixa o arquivo, sem apresentar token, até ela expirar.
| Aspecto | Como funciona aqui |
|---|---|
| Prazo | PRESIGNED_URL_EXPIRY, padrão 3600 segundos (1 hora), tanto para upload quanto para download. expiresAt vem em toda resposta que devolve URL |
| Prazo máximo possível | A AWS limita URL assinada com SigV4 a 7 dias por credencial de usuário IAM; com credencial temporária, ela expira junto com a credencial, mesmo que a URL diga outra coisa |
| Escopo | Uma URL vale para um objeto e uma operação: a de upload permite PUT naquela chave com aquele Content-Type; a de download permite GET naquela chave. Não dá para listar o bucket nem alcançar outro arquivo com ela |
| Quem pode gerar | Só quem passa por token válido, permissão (FILES_CREATE para upload, FILES_READ para download) e organização no token — e só para arquivo que pertence àquela organização |
| Com que credencial | Com a credencial S3 do serviço. As permissões da URL são as do serviço, não as do usuário que a pediu — por isso a autorização precisa acontecer antes, no building block |
| Reuso | A mesma URL funciona quantas vezes quiser até expirar. Não é de uso único |
O risco de vazamento, dito de frente
Se a URL de download vazar — colada em um chamado de suporte, registrada em log de proxy, enviada em mensagem — qualquer pessoa baixa aquele arquivo até o prazo acabar.
flowchart LR
G["URL de download emitida"] --> V{"Vazou?"}
V -->|"não"| OK["Expira em PRESIGNED_URL_EXPIRY e some"]
V -->|"chamado de suporte, log de proxy, mensagem"| L["Quem tem a URL baixa o arquivo,<br/>sem apresentar token"]
L --> R["Não há revogação individual<br/>só rotacionar a credencial S3 ou apagar o objeto"]Recomendações práticas:
- Não registre a URL completa em log de aplicação. Registre o
fileId. - Gere a URL no momento do uso e entregue-a ao usuário final, em vez de guardá-la em banco ou em cache.
- Encurte
PRESIGNED_URL_EXPIRYse o seu uso não precisa de uma hora. Para clique imediato, minutos bastam. - Trate a URL como credencial em qualquer análise de risco: ela é um portador, não uma referência.
- Não é possível revogar uma URL já emitida sem rotacionar a credencial S3 do serviço ou apagar o objeto.
Dados sensíveis
O building block guarda metadados, não conteúdo: nome, tipo, tamanho, categoria, identificador de negócio e autoria. O conteúdo do arquivo fica no armazenamento e nunca passa pelo banco.
flowchart LR Arq["Arquivo enviado"] --> Meta["Postgres — metadados<br/>nome, tipo, tamanho, categoria,<br/>businessId, createdBy, updatedBy"] Arq --> Bytes["Armazenamento — conteúdo<br/>criptografia em repouso é do provedor"] Meta -.->|"DELETE marca deletedAt"| Sobrevive["Metadado sobrevive para auditoria"] Bytes -.->|"DELETE apaga o objeto"| Some["Conteúdo não sobrevive"]
Consequências:
- Criptografia em repouso é responsabilidade do armazenamento configurado (por exemplo, SSE do S3). O building block não cifra o conteúdo por conta própria.
- O nome do arquivo aparece na chave do objeto e na URL. Nome que contenha dado pessoal —
cpf-12345678900.pdf— expõe esse dado a quem vir a URL. Gere nomes neutros. - Exclusão lógica no banco, exclusão real no armazenamento. O
DELETEmarcadeletedAte apaga o objeto. Metadado sobrevive para auditoria; o conteúdo não. Se o seu requisito for eliminar o metadado também, é preciso expurgo à parte. - Rastro de autoria. Todo arquivo guarda
createdByeupdatedBycom ouserIddo token.
LGPD
Documento de identidade e comprovante são dado pessoal, às vezes sensível. A postura recomendada:
| Medida | Onde se aplica |
|---|---|
| Nome de arquivo neutro | Na sua aplicação, ao montar o name enviado na criação |
| Criptografia em repouso habilitada | No armazenamento configurado (SSE do bucket, por exemplo) |
| Audit Trail ligado | Para a trilha de acesso a partir dos eventos file-storage.* |
PRESIGNED_URL_EXPIRY curto | Variável de ambiente do serviço (§13) |
| Processo definido para o expurgo de metadado | Hoje é manual (§15) |
Autenticação e permissões
Toda rota exige token válido, a permissão correspondente e organização no token. Chamadas entre building blocks passam pelo facade e chegam autenticadas como o usuário sentinela 00000000-0000-0000-0000-000000000000, o que as torna distinguíveis de tráfego de cliente e é o que faz a URL de upload interna ser escolhida.
Limitações conhecidas
As treze limitações abaixo caem em quatro famílias. Nenhuma delas impede o uso em produção, mas todas mudam o que você precisa construir por fora.
flowchart TD L["Limitações conhecidas"] --> A["Volume e quota<br/>tamanho não imposto · sem quota por tenant"] L --> B["Ciclo de vida<br/>sem limpeza de abandonado · objeto órfão · sem expurgo LGPD"] L --> C["Conteúdo e integridade<br/>sem checksum · sem antivírus · sem criptografia pela aplicação · sem versionamento · sem multipart"] L --> D["Alcance e operação<br/>download interno · divergência de porta · URL não revogável · category e businessId sem validação"]
| Limitação | Impacto | Situação |
|---|---|---|
| O tamanho declarado não é imposto no upload | sizeBytes é validado até 100 MB no registro, mas a URL pré-assinada não carrega restrição de tamanho: o cliente pode enviar um arquivo maior. A confirmação grava o tamanho real medido, então o registro fica correto — mas os bytes já estão lá | Limite real depende de política do bucket. Roadmap: assinar com condição de tamanho |
| Sem quota de armazenamento por tenant | Nada impede um cliente de encher o bucket. Não há limite por organização nem contabilização de uso para cobrança | Roadmap |
| Sem limpeza de upload abandonado | Registro com uploaded: false fica indefinidamente. Objeto enviado e nunca confirmado também | Roadmap. Hoje é rotina do integrador (§11) |
| Objeto órfão se a remoção no armazenamento falhar | O DELETE marca deletedAt primeiro e só então apaga o objeto. Se a remoção falhar, a linha já está excluída e o objeto permanece, sem nada apontando para ele | Ordem escolhida para preservar auditoria. Uma varredura de reconciliação está no roadmap |
| Download interno não suportado | Consumidor de dentro do cluster que precise baixar recebe a URL pública, que pode não ser alcançável. O mecanismo existe no S3Service, mas não está habilitado no caminho de download | Documentado em file-storage-internal-presigned-urls.md |
| Sem upload multipart | Arquivo grande é um PUT único. Não há upload em partes nem retomada de envio interrompido | Roadmap |
| Sem versionamento de arquivo | Reenviar na mesma chave sobrescreve. Não há histórico de versões no building block | Depende do versionamento do bucket, se o provedor oferecer |
category e businessId sem validação | São texto livre. Os seis valores de categoria de §8 são convenção, não restrição | Por design, para não travar caso de uso novo |
| Sem criptografia de conteúdo pela aplicação | O conteúdo é gravado como veio. Criptografia em repouso depende do armazenamento configurado | Por design |
| Sem verificação de integridade | Não há conferência de checksum entre o que o cliente enviou e o que chegou. A confirmação só verifica que o objeto existe e qual o tamanho | Roadmap. A API do S3 suporta checksum em SigV4 |
| Sem varredura antivírus | Nada inspeciona o conteúdo enviado | Roadmap |
| Sem expurgo automatizado para LGPD | Exclusão de metadado é lógica; eliminação definitiva é manual | Roadmap |
| Divergência de porta em desenvolvimento | Registro diz 3005, docker-compose.yaml publica 3004. Dentro do cluster o serviço escuta em 3000 nos dois casos | Inconsistência conhecida; só afeta desenvolvimento local |
| URL pré-assinada não é revogável | Emitida, vale até expirar. Não há revogação individual | Limitação do mecanismo. Mitigue com prazo curto (§14) |
Perguntas frequentes
Por que a API não devolve o arquivo, só uma URL?
Porque o objetivo é que o byte não passe pelo seu servidor. Devolver o arquivo transformaria o building block em intermediário de tráfego, com custo de banda e de dimensionamento. A URL pré-assinada faz o cliente falar direto com o armazenamento, e a autorização acontece antes, na hora de gerar a URL.
Quanto tempo a URL de upload ou download vale?
Uma hora por padrão, configurável em PRESIGNED_URL_EXPIRY. O campo expiresAt vem em toda resposta que devolve URL. Se ela expirar antes do uso, peça outra: GET /files/:id/upload-url para upload e GET /files/:id/download-url para download. Não crie registro novo por causa disso.
Se alguém copiar a URL de download, essa pessoa consegue baixar o arquivo?
Consegue, até a URL expirar. É a natureza do mecanismo — a AWS descreve a URL pré-assinada como um token portador. Por isso o prazo é curto, a URL não deve ir para log e ela deve ser gerada no momento do uso, não guardada. §14 traz as recomendações completas.
Qual a diferença entre o File Storage e o Data Store?
O File Storage guarda bytes: PDF, imagem, áudio, planilha. O Data Store guarda documentos JSON estruturados e consultáveis pelo conteúdo. O desenho recomendado usa os dois: o binário vai para o armazenamento aqui, e lá fica o documento com os campos de negócio e o fileId que aponta para o arquivo.
Posso usar com Cloudflare R2, MinIO ou outro provedor em vez do S3 da AWS?
Pode. O serviço fala a API do S3 e o destino é configuração: S3_ENDPOINT, S3_BUCKET, credenciais e S3_FORCE_PATH_STYLE. O desenvolvimento local já roda sobre MinIO. Essa é a alavanca de custo mais relevante do building block, porque a taxa de saída varia muito entre provedores (§6).
O que acontece se eu não chamar confirm-upload?
O registro fica com uploaded: false para sempre, o sizeBytes continua sendo a sua declaração em vez da medição, e GET /files/:id/download-url responde 400. O arquivo pode até estar no armazenamento, mas o building block não o considera disponível. A confirmação é o que fecha o fluxo.
Existe limite de tamanho por arquivo?
O registro valida até 100 MB no campo sizeBytes, mas esse valor é uma declaração: a URL pré-assinada não carrega restrição de tamanho, então o cliente pode enviar mais (§15). Limite real precisa vir de política do bucket. Também não há upload em partes, então arquivo muito grande depende de uma única conexão estável.
Como impeço que a empresa A baixe o arquivo da empresa B?
Você não precisa fazer nada: o identificador da organização vem do token assinado, toda busca filtra por ele, e arquivo de outra organização responde 404. A URL de download só é gerada depois dessa verificação. Além disso, o identificador da organização é o prefixo da chave no armazenamento, o que permite uma camada extra de política por prefixo se o provedor oferecer.
O arquivo é cifrado?
Não pelo building block. O conteúdo é gravado como veio, e criptografia em repouso é responsabilidade do armazenamento configurado — no S3, por exemplo, habilitando SSE no bucket. Se o requisito for criptografia ponta a ponta com chave sua, ela precisa acontecer no cliente, antes do PUT.
Dá para guardar dado estruturado junto do arquivo?
Em parte: businessId e category cobrem a classificação básica e são filtráveis na listagem. Para além disso — campos de negócio, formulário, contexto —, o lugar certo é o Data Store, guardando o fileId junto do documento.
Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md · Nota técnica: file-storage-internal-presigned-urls.md