Catalisa.Building Blocks
Catálogo/Documentos/File Storage

File Storage

Produção

Upload e download de arquivos direto no S3, isolados por empresa cliente

7
Endpoints
1
Entidades
1
Provedores
Tenant
Escopo
3005
Porta
2025-11
Desde

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.

Para quem é
  • 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
Substitui
  • 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
O que nã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)
O que dá para fazer

8 endpoints em 2 recursos.

Explorar a API →
01

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
AtributoValor
Identificadorfile-storage
CategoriaDocumentos
EscopoTenant (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 bancostorage
StatusProdução desde 2025-11
Depende dePostgreSQL, Redis, armazenamento compatível com S3, IAM

02

O problema

negócio

O 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á.


03

Proposta de valor

negócio
AntesDepois
O arquivo trafega pelo servidor da aplicaçãoVai direto do cliente ao armazenamento, por URL pré-assinada
Um bucket por empresa cliente, com política própriaUm bucket, com o identificador da organização no início da chave
"Este usuário pode baixar?" respondido em cada serviçoFILES_READ no token, verificado no mesmo middleware de sempre
Registro de arquivo que nunca chegouConfirmação que consulta o armazenamento antes de marcar como enviado
Fornecedor de armazenamento amarrado ao códigoQualquer 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"]

04

Casos de uso reais

negócio

Caso 1 — Uma esteira de crédito recebe três documentos por proposta sem servidor de upload Cenário ilustrativo

Contexto

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 dor

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.

A solução com o BB

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 bytes
O resultado

O 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

Contexto

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 dor

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.

A solução com o BB

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"]
O resultado

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

Contexto

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 dor

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.

A solução com o BB

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 true
O resultado

Mí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

Contexto

O preço público de armazenamento e de egresso dos principais provedores, consultado em 2026-08-16.

ProvedorArmazenamentoSaída para a internetFonte
Amazon S3 StandardUS$ 0,023 por GB/mêsUS$ 0,09 por GBAWS
Cloudflare R2US$ 0,015 por GB/mêsGratuitaCloudflare
Backblaze B2US$ 6,95 por TB/mêsGratuita até três vezes o armazenamento médioBackblaze
A dor

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 StandardCusto aproximado
Guardar por um mêsUS$ 23
Devolver ao usuário uma vezUS$ 90
A solução com o BB

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"]
O resultado

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.


05

Mercado e diferenciais

negócio

Panorama

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érioCatalisa File StorageAmazon S3Cloudflare R2Backblaze B2Uploadthing
O que entregaCamada de aplicação sobre armazenamentoArmazenamento cruArmazenamento cruArmazenamento cruCamada de aplicação
Isolamento por empresa clientePronto, pelo token e pelo prefixo da chaveVocê escreve as políticasVocê escreve as políticasVocê escreve as políticasProblema da sua aplicação
Metadado de negóciobusinessId e category, com filtro na listagemSó tags de objetoSó metadados de objetoSó metadados de objetoLimitado
Confirmação de upload verificadaSim, com HEAD no objetoVocê implementaVocê implementaVocê implementaSim
Preço de armazenamentoHerda o do provedor escolhidoUS$ 0,023/GB/mêsUS$ 0,015/GB/mêsUS$ 6,95/TB/mêsIncluso no plano
Preço de saídaHerda o do provedor escolhidoUS$ 0,09/GBGratuitoGratuito até 3x o armazenadoNão discriminado
Troca de fornecedorVariável de ambiente———Não se aplica
Integração com identidade e permissãoMesma do catálogo inteiroIAM da AWSTokens da CloudflareChaves de aplicaçãoPró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

  1. 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.
  2. 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.
  3. 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.
  4. 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 éEscolhaPor quê
Precisa apenas de armazenamento e já tem camada de aplicação própriaS3, R2 ou B2 diretoNó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 tardeUploadthingEle entrega isso melhor do que nós
Distribuição global de conteúdo com cache na bordaUma CDNNão é o que este building block faz
Gestão documental de verdade — versionamento, fluxo de aprovação, política de retenção legalUm GEDIsso é 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.


06

Modelo de cobrança e ROI

negócio

Unidade 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.

ProvedorArmazenamento (500 GB)Saída (1 TB)ObservaçãoFonte 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 gratuitosA saída custa cerca de sete vezes o armazenamento neste cenárioaws.amazon.com/s3/pricing, 2026-08-16
Cloudflare R2~US$ 7,50/mês a US$ 0,015/GBUS$ 0Cobra US$ 4,50 por milhão de operações de escrita; 50 mil uploads ficam abaixo de US$ 1developers.cloudflare.com/r2/pricing, 2026-08-16
Backblaze B2~US$ 3,50/mês a US$ 6,95/TBUS$ 0 dentro da franquia de 3x o armazenamento médio (1,5 TB aqui)Acima da franquia, US$ 0,01/GBbackblaze.com/cloud-storage/pricing, 2026-08-16
Supabase Storage (Pro)US$ 25/mês com 100 GB inclusos, depois US$ 0,0213/GBUS$ 0,09/GB acima de 250 GB inclusosPreço de plataforma, não só de armazenamentosupabase.com/pricing, 2026-08-16
UploadthingUS$ 25/mês com 250 GB inclusos, US$ 0,08/GB acimaNão discriminado na tabela públicaModelo de plano, não de infraestruturauploadthing.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.

07

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&lt;T, AppError&gt;"| 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 bytes

Decisões não óbvias

  • Duas rotas para a URL de upload, de propósito. POST /files cria o registro e devolve a URL na mesma resposta, o que resolve o caso comum em uma chamada. GET /files/:id/upload-url gera 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 HEAD no objeto em vez de confiar no cliente. Marcar uploaded: true porque o cliente disse que enviou produz registro fantasma. O HEAD também é a fonte do tamanho real: sizeBytes enviado 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 DELETE marca deletedAt na 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.

MonolitoStandalone (produção)
Como sobeMontado sob /file-storage no app únicoServiço próprio, na porta do registro de módulos
Como outro BB chamaFacade local, direto pelo container TypeDIFacade remoto, por HTTP em MODULE_FILE_STORAGE_URL
Identidade da chamada entre BBsUsuário sentinelaUsuário sentinela
Comportamento de negócioIdênticoIdêntico

O comportamento de negócio é idêntico; o que muda é qual endpoint assina a URL de upload.


08

Conceitos e modelo de dados

Glossário

TermoSignifica
Arquivo (File)O registro de metadados. O byte mora no armazenamento; aqui fica quem ele é, de quem é e se chegou
partnerIdO organizationId do token. É o tenant, e é o primeiro segmento da chave no armazenamento
s3KeyO caminho do objeto: {partnerId}/{fileId}/{nome}. Único na tabela
uploadedFalso até a confirmação verificar o objeto no armazenamento. Só arquivo confirmado gera URL de download
businessIdIdentificador livre do negócio ao qual o arquivo pertence (número da proposta, id do contrato). Filtrável na listagem
categoryClassificação do arquivo. Valores previstos: identity, address, income, contract, collateral, other
URL pré-assinadaURL temporária que carrega a autorização dentro dela. Vale por PRESIGNED_URL_EXPIRY segundos, padrão 3600
Usuário sentinela00000000-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 PrismaTabelaPropósitoCampos-chave
Filestorage.filesMetadados do arquivo e ponteiro para o objetopartnerId, 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.

GrupoValores
Imagemimage/png, image/jpeg, image/webp, image/gif, image/heic
Vídeovideo/mp4, video/3gpp, video/quicktime
Áudioaudio/aac, audio/mp4, audio/mpeg, audio/ogg, audio/webm, audio/wav
Documentoapplication/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/zip
Genéricoapplication/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
EstadouploadeddeletedAtO que dá para fazer
RegistradofalsevazioPedir nova URL de upload, enviar os bytes, confirmar, excluir
DisponíveltruevazioGerar URL de download, listar, excluir
ExcluídoqualquerpreenchidoNada pela API — a linha some das buscas e responde 404

09

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étodoRotaDescriçãoPermissão
POST/file-storage/api/v1/filesRegistra o arquivo e devolve a URL de uploadFILES_CREATE
GET/file-storage/api/v1/filesLista arquivos da organização, paginado e filtrávelFILES_READ
GET/file-storage/api/v1/files/:fileIdMetadados de um arquivoFILES_READ
POST/file-storage/api/v1/files/:fileId/confirm-uploadVerifica o objeto e marca como enviadoFILES_CREATE
GET/file-storage/api/v1/files/:fileId/upload-urlGera nova URL de upload para um registro existenteFILES_CREATE
GET/file-storage/api/v1/files/:fileId/download-urlGera URL temporária de downloadFILES_READ
DELETE/file-storage/api/v1/files/:fileIdExclusão lógica no banco e remoção do objetoFILES_DELETE

Saúde

MétodoRotaDescriçãoPermissão
GET/file-storage/healthSonda de saúde do serviçoPú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/download nem /files/:id/confirm. Os nomes reais são download-url e confirm-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

json
{
  "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"
}
CampoTipoObrigatórioDescrição
namestring (1–255)SimNome do arquivo. Vira o último segmento da chave no armazenamento
mimeTypestringSimPrecisa estar na lista de 23 tipos aceitos (§8). Fora dela, 400
sizeBytesnumber inteiroNãoAté 104.857.600 (100 MB). É declaração, substituída pela medição na confirmação
businessIdstring (até 100)NãoIdentificador do negócio ao qual o arquivo pertence
categorystring (até 50)NãoClassificação. Valores previstos em §8

Resposta 201

json
{
  "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

StatusQuando
400mimeType fora da lista, nome vazio, sizeBytes acima de 100 MB
401Token ausente ou inválido
403Token sem organizationId, ou sem FILES_CREATE
500Falha 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:

bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @comprovante-renda.pdf
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @comprovante-renda.pdf

O 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

StatusQuando
400File has not been uploaded to storage — o objeto não está lá. Reenvie os bytes antes de confirmar
404Arquivo 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

json
{
  "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

StatusQuando
400File has not been uploaded yet — não se gera download de arquivo não confirmado
404Arquivo 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âmetroDescriçã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.


10

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

bash
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.app
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.app

2. Registrar o arquivo e pegar a URL de upload

bash
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}'
json
{ "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

bash
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @comprovante-renda.pdf
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @comprovante-renda.pdf

Devolve 200. Repare que esta chamada não passa pelo building block — os bytes vão do seu terminal ao armazenamento.

4. Confirmar o upload

bash
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}'
json
{ "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

bash
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.pdf
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.pdf

O 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

bash
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.


11

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 true

1. Seu backend pede a URL. O token do IAM fica no servidor; o navegador recebe só a URL assinada.

javascript
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.

javascript
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.

javascript
await fetch(`/api/anexos/${data.id}/confirmar`, { method: 'POST' })
await fetch(`/api/anexos/${data.id}/confirmar`, { method: 'POST' })

Armadilhas.

  • O Content-Type do PUT precisa ser exatamente o mimeType declarado 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 fetch com FormData aqui. A URL pré-assinada espera o corpo cru, não multipart/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-url em 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.

bash
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\"}"
done
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\"}"
done

Cada 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.

bash
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}'
json
{ "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.
  • category també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]=true ao 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.

bash
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}'
json
{ "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.

bash
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: false fica 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 PUT pode 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 exemplo http://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".

12

Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token com organizationId e as permissões FILES_*. É de onde vem o tenantSim
Data StoreO irmão: guarda o documento JSON com os campos de negócio e o fileId que aponta para o arquivoNão
E-SignatureGuarda aqui o documento a ser assinado e o resultado assinadoNão
WPPMaterializa aqui a mídia recebida por WhatsApp, usando a URL de upload internaNão
Webhooks EngineEntrega os eventos file-storage.* a sistemas externos do clienteNão
Audit TrailRegistra quem enviou, leu e apagou arquivoNã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 com file-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.


13

Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
S3_ACCESS_KEY_IDCredencial de acesso ao armazenamento. O serviço não instancia o cliente S3 sem elaSim—
S3_SECRET_ACCESS_KEYSegredo da credencial. Mesma regra acimaSim—
S3_BUCKETNome do bucketNãofiles
S3_REGIONRegião usada na assinaturaNãous-east-1
S3_ENDPOINTEndpoint interno, alcançável de dentro do cluster (ex.: http://minio:9000)Não—
S3_PUBLIC_ENDPOINTEndpoint público, usado para assinar URL destinada a cliente externo. Sem ele, tudo é assinado contra S3_ENDPOINTNão—
S3_FORCE_PATH_STYLEBucket no caminho em vez de no subdomínio. Necessário para MinIONãotrue
PRESIGNED_URL_EXPIRYValidade das URLs pré-assinadas, em segundosNão3600
DATABASE_URLPostgreSQL. O schema storage vive neleSim—
REDIS_URLRedis. Usado só pelos contadores do rate limit globalSim—
JWT_SECRETSegredo compartilhado para verificar o token do IAM (mínimo 44 caracteres)Sim—
RATE_LIMIT_ENABLEDLiga o rate limit global por IPNãotrue
MODULE_FILE_STORAGE_URLURL do serviço, usada pelo facade remoto em standaloneEm standalone—
PORTPorta no modo standaloneNão3000

Divergência de porta conhecida. O registro de módulos (src/shared/registry/types.ts) declara file-storage: 3005, e é o valor autoritativo. O docker-compose.yaml de desenvolvimento publica 3004:3000, e READMEs antigos citam 3004. Dentro do cluster o serviço escuta sempre em 3000, 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ênciaPara quê
PostgreSQLSchema storage, tabela files (só metadados)
Armazenamento compatível com S3Onde os bytes moram. MinIO no desenvolvimento local, S3 ou equivalente em produção
RedisContadores 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
IAMVerificação do token e resolução de permissões

Limites e quotas

LimiteValorOnde é aplicado
Tamanho do corpo da requisição1 MiBapplyCommonMiddleware. Não limita o arquivo: os bytes vão direto ao armazenamento, sem passar por aqui
Rate limit global10.000 requisições por IP a cada 60 segundosrateLimitMiddleware, 429 acima disso
sizeBytes declarado100 MB (104.857.600)Schema Zod da rota. É declaração, não é imposta ao armazenamento (§15)
Nome do arquivo255 caracteres400 acima disso
businessId100 caracteres400 acima disso
category50 caracteres400 acima disso
Tipos MIME aceitos23 valores (§8)400 fora da lista
Validade da URL pré-assinada3600 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

StatusCódigoSignificaO que fazer
400VALIDATIONmimeType fora da lista, campo fora do tamanho, corpo reprovado no ZodConfira contra §9
400VALIDATIONFile has not been uploaded to storageOs bytes não chegaram. Reenvie na uploadUrl antes de confirmar
400VALIDATIONFile has not been uploaded yetConfirme o upload antes de pedir URL de download
401UNAUTHORIZEDToken ausente, inválido ou expiradoRenove o token no IAM
403—Token sem organizationIdAutentique informando a organização
403FORBIDDENFalta FILES_CREATE, FILES_READ ou FILES_DELETEConfira as permissões no token
404NOT_FOUNDArquivo inexistente, excluído, ou de outra organizaçãoConfira o id. A indistinção é proposital
429too_many_requestsRate limit global por IP estouradoAplique recuo exponencial; o cabeçalho Retry-After diz quanto esperar
500INTERNALFalha ao assinar URL, ao consultar o armazenamento ou ao gravar no bancoVerifique 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/health devolve 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.* carregam userId e organizationId, 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: false mais velhos que algumas horas. É o indicador direto de upload que está falhando no caminho entre cliente e armazenamento.

14

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:

  1. Autenticação — authMiddleware verifica a assinatura do token.
  2. Autorização — requirePermission(FILES_CREATE | FILES_READ | FILES_DELETE).
  3. Contexto obrigatório — requireOrganization devolve 403 para token sem organizationId.
  4. 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 responder 404, indistinguível de inexistente.
  5. 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.

AspectoComo funciona aqui
PrazoPRESIGNED_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ívelA 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
EscopoUma 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 gerarSó 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 credencialCom 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
ReusoA 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_EXPIRY se 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 DELETE marca deletedAt e 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 createdBy e updatedBy com o userId do token.

LGPD

Documento de identidade e comprovante são dado pessoal, às vezes sensível. A postura recomendada:

MedidaOnde se aplica
Nome de arquivo neutroNa sua aplicação, ao montar o name enviado na criação
Criptografia em repouso habilitadaNo armazenamento configurado (SSE do bucket, por exemplo)
Audit Trail ligadoPara a trilha de acesso a partir dos eventos file-storage.*
PRESIGNED_URL_EXPIRY curtoVariável de ambiente do serviço (§13)
Processo definido para o expurgo de metadadoHoje é 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.


15

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çãoImpactoSituação
O tamanho declarado não é imposto no uploadsizeBytes é 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 tenantNada impede um cliente de encher o bucket. Não há limite por organização nem contabilização de uso para cobrançaRoadmap
Sem limpeza de upload abandonadoRegistro com uploaded: false fica indefinidamente. Objeto enviado e nunca confirmado tambémRoadmap. Hoje é rotina do integrador (§11)
Objeto órfão se a remoção no armazenamento falharO 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 eleOrdem escolhida para preservar auditoria. Uma varredura de reconciliação está no roadmap
Download interno não suportadoConsumidor 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 downloadDocumentado em file-storage-internal-presigned-urls.md
Sem upload multipartArquivo grande é um PUT único. Não há upload em partes nem retomada de envio interrompidoRoadmap
Sem versionamento de arquivoReenviar na mesma chave sobrescreve. Não há histórico de versões no building blockDepende do versionamento do bucket, se o provedor oferecer
category e businessId sem validaçãoSão texto livre. Os seis valores de categoria de §8 são convenção, não restriçãoPor design, para não travar caso de uso novo
Sem criptografia de conteúdo pela aplicaçãoO conteúdo é gravado como veio. Criptografia em repouso depende do armazenamento configuradoPor design
Sem verificação de integridadeNã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 tamanhoRoadmap. A API do S3 suporta checksum em SigV4
Sem varredura antivírusNada inspeciona o conteúdo enviadoRoadmap
Sem expurgo automatizado para LGPDExclusão de metadado é lógica; eliminação definitiva é manualRoadmap
Divergência de porta em desenvolvimentoRegistro diz 3005, docker-compose.yaml publica 3004. Dentro do cluster o serviço escuta em 3000 nos dois casosInconsistência conhecida; só afeta desenvolvimento local
URL pré-assinada não é revogávelEmitida, vale até expirar. Não há revogação individualLimitação do mecanismo. Mitigue com prazo curto (§14)

16

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