05 de outubro de 2026·6 min de leitura
O bucket é o banco: um app de 430 linhas que eu não precisei manter
O media-viewer existe para eu assistir e baixar no celular os vídeos que meus agentes geram. Ele tem um arquivo, cinco dependências, nenhum banco e nenhum JavaScript no cliente. Este post explica cada decisão e o que eu abri mão para tomá-la.
No post anterior contei como meus agentes gravam a interface que construíram e me devolvem um link. Esse link abre o media-viewer, a última etapa do pipeline. Lá ele ganhou um parágrafo. Aqui ele ganha o post inteiro, porque é o código mais curto do kit e o que tem mais decisões por linha.
O problema era pequeno. Eu renderizava vídeos de marketing num devbox na Railway e queria assistir no celular, longe do notebook, e salvar no rolo da câmera para postar. Servidor na rede local não resolve, porque eu não estou na rede local. Mandar o arquivo por mensageiro também não, porque o vídeo é comprimido de novo e a qualidade cai.

O resultado é um server.js de pouco mais de 400 linhas e cinco dependências: Express, multer, cookie e os dois pacotes do SDK S3. Não tem banco, não tem build e não tem JavaScript no navegador. A versão pessoal roda desde agosto e teve três commits.
O bucket é o banco
A primeira vontade é criar uma tabela de projetos. Eu não criei. No media-viewer, um projeto é o primeiro segmento da chave do objeto: jure/1712345-reel.mp4 pertence ao projeto jure. Para listar os projetos, uso ListObjectsV2 com Delimiter: "/", e o S3 devolve as "pastas" em CommonPrefixes.
Isso elimina uma categoria inteira de bug. Com uma tabela, ela e o bucket podem divergir: um projeto existe na tabela e está vazio no bucket, ou um arquivo existe sem registro. Como só existe uma fonte, não tem nada para sincronizar. Criar um projeto é fazer upload do primeiro arquivo nele, e apagar um projeto é apagar os arquivos.
A regra que ficou escrita no repositório: antes de adicionar um banco, estenda o padrão de prefixo. Se um dia eu precisar de subpastas, uso um segundo segmento na chave.
O vídeo nunca passa pelo app
O servidor não faz streaming de nada. Para cada arquivo, ele gera uma URL pré-assinada do bucket, válida por uma hora, e coloca essa URL direto no src do <video>. O navegador conversa com o storage, que sabe lidar com range requests, cache e arquivos grandes. O processo Node só monta HTML.
Tem um detalhe que não é óbvio. Cada arquivo precisa de duas URLs assinadas. A primeira é para tocar inline. A segunda define ResponseContentDisposition: attachment, para que o botão Download salve o arquivo em vez de abrir o player. A disposição faz parte da assinatura, então uma URL não serve para os dois usos. Com uma URL só, o botão Download no iPhone abre o vídeo em tela cheia e não salva nada. E salvar no celular é justamente o que permite postar no Instagram depois.
Sessão sem estado
Não há contas de usuário. Existe uma senha compartilhada. Quando ela está certa, o servidor grava um cookie httpOnly com um valor e a assinatura HMAC desse valor, que vale por 30 dias. Para validar a sessão, ele recalcula a assinatura e compara em tempo constante.
Não existe session store. O estado inteiro está na assinatura, então redeploy, restart e escala horizontal não derrubam ninguém. O custo é claro: não dá para revogar uma sessão específica. Para derrubar todas, eu troco o SESSION_SECRET. Para uma ferramenta com um usuário só, que sou eu, essa troca vale a pena.
Agentes não fazem login. O POST /upload aceita também Authorization: Bearer <UPLOAD_TOKEN> e responde em JSON, com o link do projeto. É esse link que o agente me devolve:
curl -H "Authorization: Bearer $UPLOAD_TOKEN" \
-F project=meu-app -F "file=@reel.mp4;type=video/mp4" \
"$MEDIA_VIEWER_URL/upload"
# {"ok":true,"project":"meu-app","key":"meu-app/1790...-reel.mp4","url":"/p/meu-app"}
Três detalhes pequenos que evitam problema grande
- A autenticação vem antes do multer. O middleware que confere cookie ou token roda antes do parser de multipart. Uma requisição sem credencial é recusada antes de o servidor ler um byte do corpo, então ninguém consegue me fazer bufferizar 1 GB em memória sem estar autenticado.
- O healthcheck não toca no bucket. O
/healthzresponde só com o uptime. Ele diz se o processo está vivo, não se o storage está. Uma credencial errada não trava o deploy na Railway sem explicação: o app sobe, e o erro aparece na página com a mensagem do S3, que é onde eu consigo ler. - Comparação de segredo em tempo constante. Senha, token e assinatura passam por SHA-256 e depois por
timingSafeEqual. O hash iguala os tamanhos, que é a exigência dotimingSafeEqual, e não revela o tamanho do segredo.
Sem JavaScript no cliente
Cada página é HTML montado em template literal no servidor. Login, upload e navegação são formulários e links. O CSS está inline no layout, e o tema escuro foi pensado para a tela do celular.
Isso não é purismo. É o motivo de o app nunca quebrar: não há bundle para atualizar, hidratação para dar errado nem dependência de front-end para receber alerta de segurança. O <video playsinline> nativo já entrega controles, tela cheia e AirPlay. Se um dia uma feature realmente precisar de interatividade, ela entra. Até agora nenhuma precisou.
Modo público
Para o lançamento do kit eu queria uma galeria aberta, sem expor o meu viewer pessoal. A solução foi uma variável: com PUBLIC_READ=true, qualquer pessoa navega, assiste e baixa. Escrever continua exigindo senha ou token, e o formulário de upload some para quem não está logado. A demo pública é uma instância separada, com um bucket separado. O README avisa: neste modo, tudo o que está no bucket fica público, então não misture revisão privada com conteúdo de demonstração.
O que eu abri mão
A simplicidade tem limites, e prefiro que eles estejam escritos:
- O upload fica em memória. O multer bufferiza o arquivo inteiro antes do
PutObject. O teto padrão é 1 GB. Para Reels de poucos megabytes é irrelevante. Para vídeo bruto de câmera, o certo seria upload direto ao bucket com URL pré-assinada dePUT. - A listagem não pagina. O
ListObjectsV2devolve até 1.000 chaves por chamada, e o app faz uma chamada só. Com mais de mil arquivos num projeto, os mais antigos somem da página. Eu ainda não cheguei perto. - Não há miniaturas. O vídeo é servido como foi enviado, com
preload="metadata". Em celular, isso basta. - É uma senha para todos. Se eu precisar dar acesso a um cliente sem dar acesso aos outros, ou o app ganha usuários ou cada cliente ganha uma instância. Hoje eu escolheria a segunda opção.
Nenhuma dessas limitações me atrapalhou até agora. Quando alguma atrapalhar, a correção será localizada, e é por isso que eu aceitei cada uma.
Experimente
O media-viewer está dentro do agentic-media-kit, sob licença MIT. Ele funciona sozinho, sem o resto do kit: aponte para qualquer bucket compatível com S3 (Railway, R2 ou MinIO) e preencha seis variáveis de ambiente.
cd apps/media-viewer
npm install
cp .env.example .env # COOKIE_SECURE=false para http://localhost
npm run dev
O guia de deploy na Railway está em docs/deploy-railway.md. Se você tem um problema parecido, como compartilhar arquivos grandes com você mesmo ou com um cliente sem criar conta em mais um serviço, me conte como resolveu. Talvez um bucket e 400 linhas resolvam o seu também.
- Renan
Leitura relacionada
Meu agente agora grava o vídeo do que construiu
Programo pelo celular: o T3 Code comanda os agentes, eles rodam num devbox na Railway e eu recebo um "Done". Faltava ver a interface. O agentic-media-kit fecha esse ciclo: o agente grava o app, monta um Reel e me devolve um link que eu abro no celular.
O processo que está no código, não o que está na cabeça
Criei uma skill que lê o código de uma aplicação e reconstrói seus processos de negócio em BPMN validado. No caminho, abri um PR no Mermaid, descobri que outra pessoa estava mais adiantada e escolhi colaborar em vez de competir.
Eficiência sistêmica: software deveria ser tão sólido quanto um contrato bem redigido
A tese que orienta meu trabalho: tratar sistemas como contratos - antecipando modos de falha, eliminando ambiguidade e removendo fricção no nível certo, não no mais barato.