October 05, 2026·6 min read
The bucket is the database: a 430-line app I never had to maintain
media-viewer exists so I can watch and download, on my phone, the videos my agents render. It's one file, five dependencies, no database and no client-side JavaScript. This post walks through each decision and what I gave up to make it.
In the previous post I described how my agents record the UI they built and hand me back a link. That link opens media-viewer, the last stage of the pipeline. There it got one paragraph. Here it gets the whole post, because it's the shortest code in the kit and the one with the most decisions per line.
The problem was small. I rendered marketing videos on a Railway devbox and wanted to watch them on my phone, away from the laptop, and save them to the camera roll to post. A server on the local network doesn't help, because I'm not on the local network. Sending the file through a messenger doesn't either, because the video gets recompressed and the quality drops.

The result is a server.js of just over 400 lines with five dependencies: Express, multer, cookie, and the two S3 SDK packages. No database, no build step, no JavaScript in the browser. My personal instance has been running since August and has had three commits.
The bucket is the database
The first instinct is to create a projects table. I didn't. In media-viewer, a project is the first segment of the object key: jure/1712345-reel.mp4 belongs to project jure. To list projects I call ListObjectsV2 with Delimiter: "/", and S3 returns the "folders" in CommonPrefixes.
That removes a whole category of bugs. With a table, the table and the bucket can drift: a project exists in the table and is empty in the bucket, or a file exists with no record. With a single source, there's nothing to sync. Creating a project means uploading its first file, and deleting a project means deleting its files.
The rule written down in the repo: before adding a database, extend the prefix pattern. If I ever need subfolders, I'll use a second key segment.
The video never goes through the app
The server streams nothing. For each file it generates a presigned bucket URL, valid for one hour, and puts that URL straight into the <video> tag's src. The browser talks to the storage, which knows how to handle range requests, caching and large files. The Node process only renders HTML.
There's one non-obvious detail. Each file needs two signed URLs. The first plays inline. The second sets ResponseContentDisposition: attachment so the Download button saves the file instead of opening the player. The disposition is part of the signature, so one URL can't serve both uses. With a single URL, Download on an iPhone opens the video full screen and saves nothing. And saving to the phone is exactly what makes posting to Instagram possible afterwards.
Stateless sessions
There are no user accounts. There's one shared password. When it's right, the server sets an httpOnly cookie holding a value plus its HMAC signature, valid for 30 days. To validate a session it recomputes the signature and compares in constant time.
There's no session store. The whole state lives in the signature, so redeploys, restarts and horizontal scaling log nobody out. The cost is explicit: you can't revoke one specific session. To kill all of them, I rotate SESSION_SECRET. For a tool with a single user, which is me, that trade is worth it.
Agents don't log in. POST /upload also accepts Authorization: Bearer <UPLOAD_TOKEN> and answers in JSON with the project link. That's the link the agent hands back to me:
curl -H "Authorization: Bearer $UPLOAD_TOKEN" \
-F project=my-app -F "file=@reel.mp4;type=video/mp4" \
"$MEDIA_VIEWER_URL/upload"
# {"ok":true,"project":"my-app","key":"my-app/1790...-reel.mp4","url":"/p/my-app"}
Three small details that prevent big problems
- Auth runs before multer. The middleware that checks the cookie or token runs before the multipart parser. A request without credentials is rejected before the server reads a byte of the body, so nobody can make me buffer 1 GB in memory without being authenticated.
- The healthcheck doesn't touch the bucket.
/healthzonly returns uptime. It says whether the process is alive, not whether storage is. A wrong credential doesn't leave a Railway deploy stuck with no explanation: the app comes up, and the error shows on the page with the S3 message, which is where I can actually read it. - Constant-time secret comparison. Password, token and signature go through SHA-256 and then
timingSafeEqual. The hash equalizes lengths, whichtimingSafeEqualrequires, and doesn't leak the secret's length.
No client-side JavaScript
Every page is HTML built from template literals on the server. Login, upload and navigation are forms and links. The CSS is inline in the layout, and the dark theme was designed for a phone screen.
This isn't purism. It's why the app never breaks: there's no bundle to update, no hydration to go wrong, and no front-end dependency to get security alerts for. The native <video playsinline> already provides controls, full screen and AirPlay. If a feature ever genuinely needs interactivity, it'll get it. None has so far.
Public mode
For the kit's launch I wanted an open gallery without exposing my personal viewer. The answer was one variable: with PUBLIC_READ=true, anyone can browse, watch and download. Writing still requires the password or the token, and the upload form disappears for visitors who aren't signed in. The public demo is a separate instance with a separate bucket. The README warns: in this mode everything in the bucket is public, so don't mix private reviews with demo content.
What I gave up
Simplicity has limits, and I'd rather have them written down:
- Uploads are buffered in memory. multer holds the whole file before the
PutObject. The default cap is 1 GB. For Reels of a few megabytes it doesn't matter. For raw camera footage, the right move would be a direct-to-bucket upload with a presignedPUTURL. - Listing doesn't paginate.
ListObjectsV2returns up to 1,000 keys per call, and the app makes a single call. With more than a thousand files in a project, the oldest ones fall off the page. I haven't come close yet. - No thumbnails. Video is served as uploaded, with
preload="metadata". On a phone, that's enough. - One password for everyone. If I need to give one client access without giving it to the others, either the app gets users or each client gets their own instance. Today I'd pick the second option.
None of these limits has gotten in my way so far. When one does, the fix will be local, and that's why I accepted each of them.
Try it
media-viewer lives inside agentic-media-kit, MIT licensed. It works on its own, without the rest of the kit: point it at any S3-compatible bucket (Railway, R2 or MinIO) and fill in six environment variables.
cd apps/media-viewer
npm install
cp .env.example .env # COOKIE_SECURE=false for http://localhost
npm run dev
The Railway deploy guide is in docs/deploy-railway.md. If you have a similar problem, like sharing large files with yourself or a client without signing up for yet another service, tell me how you solved it. Maybe a bucket and 400 lines will solve yours too.
- Renan
Related reading
My coding agent now records what it built
I code from my phone: T3 Code drives the agents, they run on a Railway devbox, and I get a "Done". What was missing was seeing the UI. agentic-media-kit closes that loop: the agent records the app, cuts a Reel, and hands me a link I open on my phone.
The process in the code, not the one in your head
I built a skill that reads an application's code and reconstructs its business processes as validated BPMN. Along the way I opened a PR on Mermaid, found someone further ahead, and chose to collaborate instead of compete.
Systemic efficiency: software should be as solid as a well-drafted contract
The thesis behind my work: treat systems like contracts - anticipating failure modes, eliminating ambiguity, and removing friction at the level that matters, not the cheapest one.