A self-hosted file versioning and delivery system for managing application binaries, firmware updates, or any versioned files with a REST API.
VersionStack/
├── server/ # Node.js/Express backend
│ ├── src/
│ │ ├── index.ts # Server entry point
│ │ ├── container.ts # TSyringe DI container setup
│ │ ├── db/ # Database module
│ │ │ ├── index.ts # Database initialization
│ │ │ ├── migrator.ts # Umzug migration runner
│ │ │ ├── cli.ts # Migration CLI
│ │ │ └── migrations/ # Migration files
│ │ ├── controllers/ # HTTP request handlers (thin layer)
│ │ │ ├── base.controller.ts
│ │ │ ├── apps.controller.ts
│ │ │ ├── versions.controller.ts
│ │ │ ├── auth.controller.ts
│ │ │ ├── audit.controller.ts
│ │ │ └── health.controller.ts
│ │ ├── services/ # Business logic layer
│ │ │ ├── apps.service.ts
│ │ │ ├── versions.service.ts
│ │ │ ├── auth.service.ts
│ │ │ └── audit.service.ts
│ │ ├── repositories/ # Data access layer
│ │ │ ├── base.repository.ts # Transaction support
│ │ │ ├── apps.repository.ts
│ │ │ ├── versions.repository.ts
│ │ │ ├── api-keys.repository.ts
│ │ │ └── audit.repository.ts
│ │ ├── storage/ # File storage abstraction
│ │ │ └── file-storage.ts
│ │ ├── errors/ # Custom error classes
│ │ │ └── index.ts
│ │ ├── types/ # TypeScript interfaces
│ │ ├── middleware/ # Express middleware (auth, validation)
│ │ ├── routes/ # Route definitions (wires controllers)
│ │ │ ├── v1/index.ts # V1 API routes using controllers
│ │ │ ├── apps.ts # Legacy routes (deprecated)
│ │ │ ├── versions.ts
│ │ │ ├── auth.ts
│ │ │ ├── audit.ts
│ │ │ └── health.ts
│ │ ├── openapi/ # OpenAPI documentation
│ │ └── utils/ # Utility functions (responses, audit)
│ ├── tests/ # Unit tests (Vitest)
│ │ ├── setup.ts
│ │ └── services/
│ ├── data/ # Runtime data (SQLite DB, uploaded files)
│ └── Dockerfile
├── client/ # Vue 3 frontend
│ ├── src/
│ │ ├── views/ # Page components
│ │ ├── components/ # Reusable components
│ │ └── api/ # API client layer
│ │ ├── index.ts # API wrapper with auth interceptors
│ │ └── generated/ # Auto-generated TypeScript client
│ └── Dockerfile
├── nginx/ # Nginx reverse proxy config
├── docker-compose.yml # Development environment
├── docker-compose.prod.yml # Production environment
└── generate-api-client.bat # Script to regenerate API client
- Runtime: Node.js with TypeScript
- Framework: Express.js
- Database: SQLite (via
sqlite3+sqlite) - Dependency Injection: TSyringe (decorator-based DI)
- Validation: Zod
- API Documentation: OpenAPI 3.0 via
@asteasolutions/zod-to-openapi - Authentication: JWT (jsonwebtoken)
- File Upload: Multer
- Testing: Vitest
- Framework: Vue 3 with Composition API
- Build Tool: Vite
- HTTP Client: Generated TypeScript/Axios client from OpenAPI
- Styling: Bootstrap 5
- Containerization: Docker + Docker Compose
- Reverse Proxy: Nginx (serves static files, proxies API)
The server uses a 4-layer architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────────┐
│ HTTP Layer (Express) │
├─────────────────────────────────────────────────────────────┤
│ Controllers (thin) │
│ - Parse request, call service, return response │
│ - Error handling via BaseController │
├─────────────────────────────────────────────────────────────┤
│ Services (business logic) │
│ - Orchestrate operations │
│ - Transaction boundaries │
│ - Coordinate repositories + file storage │
├─────────────────────────────────────────────────────────────┤
│ Repositories (data access) │ FileStorage (abstraction) │
│ - SQL queries │ - Save/delete files │
│ - Type-safe returns │ - Hash calculation │
│ - Transaction support │ - Directory management │
├─────────────────────────────────────────────────────────────┤
│ Database (SQLite) │
└─────────────────────────────────────────────────────────────┘
- Controllers: HTTP-only concerns. Parse requests, delegate to services, format responses. All controllers extend
BaseControllerfor consistent error handling. - Services: Business logic and orchestration. Define transaction boundaries, coordinate between repositories and file storage.
- Repositories: Data access layer. Execute SQL queries with type-safe returns. Extend
BaseRepositoryfor transaction support. - FileStorage: Abstraction for file operations (save, delete, hash calculation).
TSyringe provides decorator-based DI:
// Services and repositories use @injectable() decorator
@injectable()
export class AppsService {
constructor(
private appsRepo: AppsRepository,
private versionsRepo: VersionsRepository,
private fileStorage: FileStorage
) {}
}
// Container resolves dependencies automatically
const appsController = container.resolve(AppsController);Custom error classes in errors/index.ts provide consistent error responses:
throw new AppNotFoundError(appKey); // 404 APP_NOT_FOUND
throw new AlreadyExistsError('App'); // 409 ALREADY_EXISTS
throw new ValidationError('Invalid'); // 400 VALIDATION_ERRORInteractive API documentation is available via Swagger UI:
- Swagger UI:
/api/docs- Interactive API explorer - OpenAPI JSON:
/api/openapi.json- OpenAPI 3.0 specification
The frontend uses a TypeScript client auto-generated from the OpenAPI specification.
Regenerating the client:
# Windows
generate-api-client.bat
# What it does:
# 1. Starts backend + nginx containers
# 2. Waits for API to be ready
# 3. Downloads OpenAPI spec
# 4. Generates TypeScript/Axios client to client/src/api/generated/
# 5. Stops containersUsing the client in Vue components:
import { appsApi, versionsApi, authApi, type App, type Version } from '../api';
// List apps
const response = await appsApi().appsGet();
const apps: App[] = response.data;
// Create app
await appsApi().appsPost({ appKey: 'my-app', displayName: 'My App' });
// Get versions
const versions = await versionsApi().appsAppKeyVersionsGet('my-app');
// Login with API key
const loginRes = await authApi().authLoginPost({ apiKey: 'your-api-key' });
localStorage.setItem('token', loginRes.data.token);For file uploads with progress tracking, use the axios instance directly:
import { axiosInstance } from '../api';
await axiosInstance.post(`/api/v1/apps/${appKey}/versions`, formData, {
onUploadProgress: (e) => console.log(`${Math.round(e.loaded * 100 / e.total!)}%`)
});All API endpoints are versioned under /api/v1/. Legacy unversioned endpoints (/api/) are deprecated.
POST /api/v1/auth/login- Authenticate with API key, get JWT tokenGET /api/v1/auth/file-access- Internal endpoint for Nginx auth_request (validates file download permissions)
GET /api/v1/auth/api-keys- List all API keysPOST /api/v1/auth/api-keys- Create new API keyDELETE /api/v1/auth/api-keys/:keyId- Revoke API key
GET /api/v1/apps- List all appsGET /api/v1/apps/:appKey- Get single appPOST /api/v1/apps- Create appPUT /api/v1/apps/:appKey- Update app display nameDELETE /api/v1/apps/:appKey- Delete app and all versions
GET /api/v1/apps/:appKey/versions- List all versionsPOST /api/v1/apps/:appKey/versions- Upload new version (multipart, supports multiple files)DELETE /api/v1/apps/:appKey/versions/:versionId- Delete versionPUT /api/v1/apps/:appKey/active-version- Set active versionGET /api/v1/apps/:appKey/latest- Get active version info (public for public apps, auth required for private apps)
GET /api/v1/audit- Get audit log with optional filters (?action=, ?entityType=, ?entityId=, ?limit=, ?offset=)
GET /api/v1/health- Full health statusGET /api/v1/health/live- Liveness probeGET /api/v1/health/ready- Readiness probe
-- Apps table
apps (
id INTEGER PRIMARY KEY,
app_key TEXT UNIQUE NOT NULL, -- URL-safe identifier (alphanumeric + dashes)
display_name TEXT,
current_version_id INTEGER, -- Active version FK
is_public INTEGER DEFAULT 0, -- 1 = /latest is public, 0 = requires auth
created_at DATETIME
)
-- Versions table
versions (
id INTEGER PRIMARY KEY,
app_id INTEGER NOT NULL,
version_name TEXT NOT NULL, -- e.g., "1.0.0"
is_active BOOLEAN,
created_at DATETIME
)
-- Version files table (supports multiple files per version)
version_files (
id INTEGER PRIMARY KEY,
version_id INTEGER NOT NULL,
file_name TEXT NOT NULL,
file_hash TEXT NOT NULL, -- SHA256 hash
file_size INTEGER NOT NULL,
created_at DATETIME
)
-- API keys table
api_keys (
id INTEGER PRIMARY KEY,
key_hash TEXT UNIQUE NOT NULL, -- SHA256 hash of the API key
name TEXT NOT NULL, -- Human-readable label
permission TEXT NOT NULL, -- 'read', 'write', or 'admin'
app_scope TEXT, -- NULL=global, or JSON array of app_keys
is_active INTEGER DEFAULT 1,
created_at DATETIME,
last_used_at DATETIME,
created_by_key_id INTEGER -- FK to api_keys.id (NULL for bootstrap)
)
-- Audit log table
audit_log (
id INTEGER PRIMARY KEY,
action TEXT NOT NULL, -- e.g., 'app.create', 'auth.login_failed'
entity_type TEXT NOT NULL, -- 'app', 'version', 'api_key', 'auth'
entity_id TEXT, -- Identifier of affected entity
actor_key_id INTEGER, -- FK to api_keys.id (NULL for bootstrap admin)
actor_ip TEXT, -- IP address of request
details TEXT, -- JSON with additional context
created_at DATETIME
)- Must be alphanumeric characters separated by dashes
- Valid:
my-app,app123,my-app-v2 - Invalid:
my_app,my app,-myapp,myapp-
Responses return data directly without wrappers. HTTP status codes indicate success/failure. All API responses and requests use camelCase for property names.
// Success (200, 201)
{ "id": 1, "appKey": "my-app", "displayName": "My App", ... }
// Error (4xx, 5xx)
{
"code": "ERROR_CODE",
"message": "Human readable message",
"details": { "field": ["error1"] } // optional, for validation errors
}VALIDATION_ERROR- Input validation failedUNAUTHORIZED- No/invalid token or API keyFORBIDDEN- Insufficient permissionsAPP_NOT_FOUND- App doesn't existVERSION_NOT_FOUND- Version doesn't existALREADY_EXISTS- Resource already existsCONFLICT- Operation conflict
# Start all services
docker-compose up --build
# Access
# - Frontend: http://localhost
# - API: http://localhost/api/v1/
# - API Docs: http://localhost/api/docsPORT=3000
JWT_SECRET=your-secret-key
ADMIN_API_KEY=your-admin-api-key # Bootstrap admin key (generate with: openssl rand -hex 32)# Remove volumes to clear cached node_modules
docker-compose down -v
docker-compose up --buildUnit tests use Vitest with mocked dependencies. Tests are located in server/tests/.
cd server
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage report
npm run test:coverageserver/tests/
├── setup.ts # Test setup, mock utilities
├── services/ # Service unit tests
│ └── apps.service.test.ts
└── repositories/ # Repository unit tests (planned)
Services are tested by mocking their dependencies:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AppsService } from '../../src/services/apps.service';
describe('AppsService', () => {
let service: AppsService;
let mockAppsRepo: any;
beforeEach(() => {
mockAppsRepo = {
findByKey: vi.fn(),
delete: vi.fn(),
withTransaction: vi.fn((fn) => fn()),
};
service = new AppsService(mockAppsRepo, mockVersionsRepo, mockFileStorage);
});
it('should delete app', async () => {
mockAppsRepo.findByKey.mockResolvedValue({ id: 1 });
await service.deleteApp('test-app', {} as any);
expect(mockAppsRepo.delete).toHaveBeenCalledWith(1);
});
});Migrations are managed with umzug and stored in server/src/db/migrations/.
# Run all pending migrations (also runs automatically on server start)
npm run migrate
# Revert the last migration
npm run migrate:down
# Show migration status
npm run migrate:status
# Create a new migration
npm run migrate:create <name>server/src/db/migrations/
├── 001_initial_schema.ts # Apps and versions tables
├── 002_version_files_table.ts # Multi-file support
├── 003_add_indexes.ts # Performance indexes
├── 004_remove_legacy_file_columns.ts # Schema cleanup
├── 005_api_keys_table.ts # API keys for authentication
├── 006_add_app_public_flag.ts # Public/private flag for apps
└── 007_audit_log_table.ts # Audit trail for security monitoring
npm run migrate:create add_user_table
# Creates: src/db/migrations/1703520000000_add_user_table.tsimport { Database } from 'sqlite';
interface MigrationContext {
db: Database;
}
export async function up({ db }: MigrationContext): Promise<void> {
await db.exec(`
CREATE TABLE example (id INTEGER PRIMARY KEY);
`);
}
export async function down({ db }: MigrationContext): Promise<void> {
await db.exec(`DROP TABLE IF EXISTS example`);
}Uploaded files are stored at:
data/files/{appKey}/{versionName}/{fileName}
Files are served by Nginx at /files/... with authentication protection via auth_request.
File downloads respect app visibility settings:
- Public apps: Files accessible without authentication
- Private apps: Files require valid JWT token with app access
Nginx uses auth_request to call /api/v1/auth/file-access before serving files. This endpoint validates:
- The app exists
- If public, allows access immediately
- If private, verifies the JWT token and checks app scope permissions
- API Key Authentication: Bootstrap admin key via env var, additional keys stored in database
- Permission Levels:
read: GET endpoints onlywrite: Full CRUD on apps/versionsadmin: Full access + API key management + audit log
- App Scoping: API keys can be limited to specific apps
- Public/Private Apps: Apps can be marked public (unauthenticated /latest access) or private
- File Download Protection: Nginx
auth_requestvalidates permissions before serving files from private apps - Audit Logging: All actions are logged with actor, IP, timestamp, and details:
auth.login,auth.login_failedapi_key.create,api_key.revokeapp.create,app.update,app.deleteversion.upload,version.delete,version.set_active
- JWT tokens for session management (12h expiry)
- Input validation with Zod schemas
- Input sanitization (XSS prevention, filename sanitization)
- Rate limiting (100 requests per 15 minutes per IP)
- App key validation (alphanumeric + dashes only)
- SHA256 file hashing for integrity verification
- API keys stored as SHA256 hashes (never plaintext)