Upload and download private files

A file ID is not permission to read it. Start with a small, authenticated text-file endpoint and keep the policy visible.

On this pageSource-backed Markdown

Set up the lab

Use Node.js 24, the installed app and development tools from Installation, and a dedicated SQL account allowed to create/drop db3_app_test_* databases. The lab creates the Auth and Media tables, two users and a temporary storage directory; it removes them afterwards.

Install Fastify explicitly because this application owns its HTTP adapter. No public account registration route or web UI is added by this lab.

Install the HTTP adapter
bash
npm install fastify@5

Copy the files

Copy the shipped Media examples into your independent application. The endpoint and runner stay separate so your application can reuse the routes with its own boot and shutdown policy.

Copy the shipped example
bash
mkdir -p examples
cp -R node_modules/@db3.ai/app/src/media/examples/. examples/

Run the workflow

Expect upload 201, missing authentication 401, another user’s read/delete 404, empty or JSON uploads 400, oversized input 413, deletion 204, and a successful retry 201. Unsupported content types such as application/octet-stream are rejected earlier by Fastify with 415. The test checks that rejected uploads create no file rows and that deletion removes the bytes.

Run the lab
bash
npx tsx examples/runPrivateFiles.ts

Keep authorization next to the file lookup

POST /files accepts non-empty text/plain up to 64 KiB. Auth resolves the user from a bearer token. The owner and generated path come from the application, never a submitted owner ID or filename.

GET /files/:id and DELETE /files/:id resolve the current user’s library, then query by both file ID and library. A missing or foreign file gets the same 404. Scope metadata does not enforce permissions by itself.

Each request enters application.requestContext.run(). The response selects only the upload ID/name/size, rather than returning storage paths or auth records.

examples/createPrivateFileServer.ts
ts
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
import { MediaFile } from '@db3.ai/app/media';
import type { App } from '@db3.ai/app/server';

/**
 * Builds a deliberately small private-text-file API without opening a socket.
 *
 * The caller owns App and server cleanup. Uploads are bounded to 64 KiB and
 * buffered by the HTTP parser; downloads stream only after owner authorization.
 * Bearer tokens are lab inputs. A deployed endpoint requires HTTPS and an
 * application-owned token issuance and storage policy.
 *
 * @param application - Active application with Auth, Media and local storage.
 * @returns Injectable HTTP server exposing POST, GET and DELETE /files.
 */
export function createPrivateFileServer(application: App): FastifyInstance {
	const server = Fastify({ bodyLimit: 64 * 1024 });
	server.addHook('onRequest', (_request, _reply, done) => application.requestContext.run(done));

	/** Resolves a bearer token without accepting a caller-supplied owner id. */
	async function owner(request: FastifyRequest, reply: FastifyReply): Promise<string | null> {
		const authorization = request.headers.authorization;
		const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
		const user = token ? await application.auth.authenticateToken(token) : null;
		if (!user?.id) { reply.code(401).send({ error: 'Sign in first.' }); return null; }
		return user.id;
	}

	server.post<{ Body: string }>('/files', async (request, reply) => {
		const ownerId = await owner(request, reply);
		if (!ownerId) return;
		if (request.headers['content-type']?.split(';')[0]?.trim() !== 'text/plain' || typeof request.body !== 'string' || !request.body.trim()) return reply.code(400).send({ error: 'Supply non-empty text/plain.' });
		const library = await application.media.libraryFor({ scopeType: 'user', scopeId: ownerId, pathPrefix: `users/${ownerId}` });
		const file = await application.media.storeFile({ library, name: 'brief.txt', mimeType: 'text/plain', contents: request.body, visibility: 'private', source: 'upload' });
		return reply.code(201).send({ id: file.id, name: file.name, size: file.size });
	});
	server.get<{ Params: { id: string } }>('/files/:id', async (request, reply) => {
		const ownerId = await owner(request, reply);
		if (!ownerId) return;
		const library = await application.media.libraryFor({ scopeType: 'user', scopeId: ownerId });
		const file = await MediaFile.where({ id: request.params.id, library }).first();
		if (!file) return reply.code(404).send({ error: 'File not found.' });
		return reply.header('Cache-Control', 'private, no-store').header('Content-Type', 'text/plain; charset=utf-8').header('Content-Disposition', 'attachment; filename="brief.txt"').header('X-Content-Type-Options', 'nosniff').send(await application.media.readFileStream(file));
	});
	server.delete<{ Params: { id: string } }>('/files/:id', async (request, reply) => {
		const ownerId = await owner(request, reply);
		if (!ownerId) return;
		const library = await application.media.libraryFor({ scopeType: 'user', scopeId: ownerId });
		const file = await MediaFile.where({ id: request.params.id, library }).first();
		if (!file) return reply.code(404).send({ error: 'File not found.' });
		await application.media.deleteFile(file);
		return reply.code(204).send();
	});
	return server;
}

Deliver privately

Download responses stream the already-authorized file with private, no-store, attachment disposition and nosniff. The filename is fixed, so request input cannot become a response header.

Use HTTPS outside the local lab. Keep bearer tokens out of logs, URLs and public browser storage. For a browser application, follow the starter’s server-side HttpOnly cookie transport and origin checks instead of inventing a token-storage policy.

Choose the next boundary deliberately

This small upload is bounded and buffered by Fastify. It is not a large-file upload protocol. Multipart parsing, resumable offsets, quotas, virus scanning, expiring temporary files and client-disconnect handling need an adapter with their own tests.

For images, validate the detected type and use canonical reconstruction, not this text endpoint. Public publication needs a separate policy. Deletion across SQL and storage is not atomic; references, recovery and orphan reconciliation remain application work.

Run without opening a port

The runner uses real framework services and server.inject(), so the same route code is tested without a listening socket. It never prints tokens or passwords.

examples/runPrivateFiles.ts
ts
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { AuthProvider, AuthToken, PasswordResetToken, UserIdentity } from '@db3.ai/app/auth';
import { createGeneratedTestDatabase } from '@db3.ai/app/db/test/db';
import { MediaFile, MediaItem, MediaLibrary } from '@db3.ai/app/media';
import { App } from '@db3.ai/app/server';
import { createPrivateFileServer } from './createPrivateFileServer';

/** Runs authorized upload/download/deletion against real Auth, SQL and storage. */
export async function runPrivateFiles() {
	const root = await mkdtemp(join(tmpdir(), 'db3-private-files-'));
	try {
		const database = await createGeneratedTestDatabase('private_files');
		const application = new App({ db: database.db, config: { auth: { providers: { password: true } } }, storage: { disks: { local: { driver: 'local', root } } } });
		const server = createPrivateFileServer(application);
		try {
			await application.db.install(UserIdentity, AuthProvider, AuthToken, PasswordResetToken, MediaLibrary, MediaFile, MediaItem);
			const ada = await application.auth.registerWithPassword({ name: 'Ada', email: '[email protected]', password: 'example-password-123' });
			const grace = await application.auth.registerWithPassword({ name: 'Grace', email: '[email protected]', password: 'example-password-456' });
			const headers = { authorization: `Bearer ${ada.token}`, 'content-type': 'text/plain' };
			const other = { authorization: `Bearer ${grace.token}` };
			const unsigned = await server.inject({ method: 'POST', url: '/files', headers: { 'content-type': 'text/plain' }, payload: 'Hidden' });
			const upload = await server.inject({ method: 'POST', url: '/files', headers, payload: 'Private client brief' });
			if (upload.statusCode !== 201) throw new Error(`Upload failed: ${upload.statusCode}`);
			const id = upload.json<{ id: string }>().id;
			const file = await MediaFile.where('id', id).firstOrFail();
			const path = file.path!;
			const download = await server.inject({ url: `/files/${id}`, headers });
			const stranger = await server.inject({ url: `/files/${id}`, headers: other });
			const strangerDelete = await server.inject({ method: 'DELETE', url: `/files/${id}`, headers: other });
			const empty = await server.inject({ method: 'POST', url: '/files', headers, payload: '  ' });
			const wrongType = await server.inject({ method: 'POST', url: '/files', headers: { ...headers, 'content-type': 'application/json' }, payload: { owner: grace.user.id } });
			const oversized = await server.inject({ method: 'POST', url: '/files', headers, payload: 'x'.repeat(64 * 1024 + 1) });
			const failedWritesAbsent = await MediaFile.query().count() === 1;
			const deleted = await server.inject({ method: 'DELETE', url: `/files/${id}`, headers });
			const missing = await server.inject({ url: `/files/${id}`, headers });
			const retry = await server.inject({ method: 'POST', url: '/files', headers, payload: 'Revised brief' });
			return { uploaded: upload.statusCode, text: download.body, unsigned: unsigned.statusCode, stranger: stranger.statusCode, strangerDelete: strangerDelete.statusCode, empty: empty.statusCode, wrongType: wrongType.statusCode, oversized: oversized.statusCode, failedWritesAbsent, privateHeaders: download.headers['cache-control'] === 'private, no-store' && download.headers['x-content-type-options'] === 'nosniff', deleted: deleted.statusCode, bytesRemoved: await application.storage.missing(path), missing: missing.statusCode, retry: retry.statusCode };
		} finally { try { await server.close(); } finally { try { await application.close(); } finally { await database.destroy(); } } }
	} finally { await rm(root, { recursive: true, force: true }); }
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) console.log(JSON.stringify(await runPrivateFiles(), null, 2));

Testing

Create a tests/media directory and save the test below as runPrivateFiles.test.ts. It imports the example you copied into examples/. Keep the same folder layout so the relative import resolves.

Run from the application root with the development dependencies from Installation. These are consumer tests, not commands that assume a framework checkout. This database lab needs the same test-only SQL credentials when run through Vitest.

tests/media/runPrivateFiles.test.ts
ts
import { expect, it } from 'vitest';
import { runPrivateFiles } from '../../examples/runPrivateFiles';

it('protects real managed files across users and rejects invalid uploads without writes', async () => {
	expect(await runPrivateFiles()).toEqual({ uploaded: 201, text: 'Private client brief', unsigned: 401, stranger: 404, strangerDelete: 404, empty: 400, wrongType: 400, oversized: 413, failedWritesAbsent: true, privateHeaders: true, deleted: 204, bytesRemoved: true, missing: 404, retry: 201 });
});

Run and extend the test

Change the brief and add a third user. Keep assertions for unauthorized deletion, unchanged row count after failure, byte cleanup and successful retry. Socket disconnects and proxy behavior require additional real-network tests; injection does not prove those.

Run your copied test and check types
bash
npx vitest run tests/media/runPrivateFiles.test.ts
npx tsc --noEmit --target ES2022 --module ESNext --moduleResolution Bundler --types node --skipLibCheck examples/*.ts

Coverage and next steps

Tested: real authentication, per-user library authorization, bounded text input, controlled errors, private response headers, streamed download, deletion and retry. Not implemented here: project memberships, browser upload UI, arbitrary file formats or public sharing.

Behaviour tested Executed by the documentation maintenance gate
What this does

Authenticates two users, uploads a private text file, rejects foreign reads/deletes and invalid uploads, then deletes and uploads again.

Expected outputThe guide test passes against the real framework components.
Behaviour testpackages/app/src/media/tests/examples/runPrivateFiles.test.ts

This test command requires the framework repository. Use the walkthrough commands in an installed application.

Environment: Disposable MariaDB/MySQL, temporary local storage and injected HTTP requests; no listening port or external API.