diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..f518cb8
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,31 @@
+# Server Configuration
+NODE_ENV=production
+PORT=3000
+DATA_DIR=/app/data
+
+# Session Configuration
+SESSION_SECRET=change-this-to-a-secure-random-string-at-least-32-chars
+
+# Initial Admin User (only used on first startup if no users exist)
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=change-this-secure-password
+ADMIN_EMAIL=admin@example.com
+
+# RunPod API Configuration
+RUNPOD_API_KEY=your-runpod-api-key
+RUNPOD_ENDPOINT_ID=your-endpoint-id
+
+# WebAuthn Configuration
+WEBAUTHN_RP_ID=localhost
+WEBAUTHN_RP_NAME=ComfyUI Video Generator
+WEBAUTHN_ORIGIN=http://localhost:3000
+
+# Security
+ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
+TRUST_PROXY=true
+
+# Rate Limiting
+RATE_LIMIT_WINDOW_MS=60000
+RATE_LIMIT_MAX_REQUESTS=100
+LOGIN_RATE_LIMIT_MAX=5
+LOGIN_RATE_LIMIT_WINDOW_MS=900000
diff --git a/frontend/.gitea/workflows/build.yaml b/frontend/.gitea/workflows/build.yaml
new file mode 100644
index 0000000..8cc1837
--- /dev/null
+++ b/frontend/.gitea/workflows/build.yaml
@@ -0,0 +1,49 @@
+name: Build and Push Frontend Docker Image
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'frontend/**'
+ - '.gitea/workflows/build-frontend.yaml'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ container:
+ image: catthehacker/ubuntu:act-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Gitea Registry
+ uses: docker/login-action@v3
+ with:
+ registry: gitea.voyager.sh
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: gitea.voyager.sh/nick/comfyui-frontend
+ tags: |
+ type=sha,prefix=
+ type=raw,value=latest
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: ./frontend
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..536ea07
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+data/
+.env
+*.log
+.DS_Store
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..7b8375e
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,64 @@
+# Build stage
+FROM node:22-alpine AS builder
+
+WORKDIR /app
+
+# Install build dependencies for native modules (argon2, better-sqlite3)
+RUN apk add --no-cache python3 make g++ sqlite-dev
+
+# Copy package files
+COPY package*.json ./
+
+# Install all dependencies (including dev)
+RUN npm ci
+
+# Copy source
+COPY tsconfig.json ./
+COPY src/ ./src/
+
+# Build TypeScript
+RUN npm run build
+
+# Prune dev dependencies
+RUN npm prune --production
+
+# Production stage
+FROM node:22-alpine
+
+WORKDIR /app
+
+# Install runtime dependencies
+RUN apk add --no-cache sqlite-libs
+
+# Create non-root user
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
+
+# Copy built files
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY package*.json ./
+
+# Copy public files
+COPY public/ ./public/
+
+# Copy database migrations
+COPY src/db/migrations/ ./dist/db/migrations/
+
+# Create data directory
+RUN mkdir -p /app/data/content && chown -R appuser:appgroup /app/data
+
+# Switch to non-root user
+USER appuser
+
+# Environment
+ENV NODE_ENV=production
+ENV DATA_DIR=/app/data
+ENV PORT=3000
+
+EXPOSE 3000
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD wget -q --spider http://localhost:3000/health || exit 1
+
+CMD ["node", "dist/index.js"]
diff --git a/frontend/docker-compose.yaml b/frontend/docker-compose.yaml
new file mode 100644
index 0000000..5c4ff04
--- /dev/null
+++ b/frontend/docker-compose.yaml
@@ -0,0 +1,32 @@
+version: '3.8'
+
+services:
+ frontend:
+ build: .
+ ports:
+ - "3000:3000"
+ environment:
+ - NODE_ENV=development
+ - SESSION_SECRET=${SESSION_SECRET:-dev-session-secret-change-in-production}
+ - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme123456}
+ - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@localhost}
+ - RUNPOD_API_KEY=${RUNPOD_API_KEY:?RUNPOD_API_KEY is required}
+ - RUNPOD_ENDPOINT_ID=${RUNPOD_ENDPOINT_ID:?RUNPOD_ENDPOINT_ID is required}
+ - WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
+ - WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-ComfyUI Video Generator}
+ - WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-http://localhost:3000}
+ - ENCRYPTION_KEY=${ENCRYPTION_KEY:-0000000000000000000000000000000000000000000000000000000000000000}
+ - TRUST_PROXY=false
+ volumes:
+ - frontend-data:/app/data
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 10s
+
+volumes:
+ frontend-data:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..7d775fd
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,2241 @@
+{
+ "name": "comfyui-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "comfyui-frontend",
+ "version": "1.0.0",
+ "dependencies": {
+ "@simplewebauthn/server": "^10.0.1",
+ "argon2": "^0.41.1",
+ "better-sqlite3": "^11.6.0",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "express-session": "^1.18.1",
+ "helmet": "^8.0.0",
+ "otpauth": "^9.3.5",
+ "pino": "^9.6.0",
+ "pino-http": "^10.4.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@types/better-sqlite3": "^7.6.12",
+ "@types/express": "^5.0.0",
+ "@types/express-session": "^1.18.1",
+ "@types/node": "^22.10.5",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@hexagon/base64": {
+ "version": "1.1.28",
+ "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
+ "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
+ },
+ "node_modules/@levischuck/tiny-cbor": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
+ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
+ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
+ "engines": {
+ "node": "^14.21.3 || >=16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@peculiar/asn1-android": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
+ "integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.6.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-ecc": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
+ "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-rsa": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
+ "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.6.0",
+ "@peculiar/asn1-x509": "^2.6.0",
+ "asn1js": "^3.0.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
+ "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
+ "dependencies": {
+ "asn1js": "^3.0.6",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/asn1-x509": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
+ "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.6.0",
+ "asn1js": "^3.0.6",
+ "pvtsutils": "^1.3.6",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@phc/format": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
+ "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
+ },
+ "node_modules/@simplewebauthn/server": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-10.0.1.tgz",
+ "integrity": "sha512-djNWcRn+H+6zvihBFJSpG3fzb0NQS9c/Mw5dYOtZ9H+oDw8qn9Htqxt4cpqRvSOAfwqP7rOvE9rwqVaoGGc3hg==",
+ "dependencies": {
+ "@hexagon/base64": "^1.1.27",
+ "@levischuck/tiny-cbor": "^0.2.2",
+ "@peculiar/asn1-android": "^2.3.10",
+ "@peculiar/asn1-ecc": "^2.3.8",
+ "@peculiar/asn1-rsa": "^2.3.8",
+ "@peculiar/asn1-schema": "^2.3.8",
+ "@peculiar/asn1-x509": "^2.3.8",
+ "@simplewebauthn/types": "^10.0.0",
+ "cross-fetch": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@simplewebauthn/types": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-10.0.0.tgz",
+ "integrity": "sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
+ },
+ "node_modules/@types/better-sqlite3": {
+ "version": "7.6.13",
+ "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "dev": true,
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+ "dev": true,
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^5.0.0",
+ "@types/serve-static": "^2"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz",
+ "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
+ "dev": true,
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
+ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "dev": true
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "dev": true
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/argon2": {
+ "version": "0.41.1",
+ "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz",
+ "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "@phc/format": "^1.0.0",
+ "node-addon-api": "^8.1.0",
+ "node-gyp-build": "^4.8.1"
+ },
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
+ },
+ "node_modules/asn1js": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
+ "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.3",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/better-sqlite3": {
+ "version": "11.10.0",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
+ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
+ "hasInstallScript": true,
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="
+ },
+ "node_modules/cross-fetch": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
+ "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
+ "dependencies": {
+ "node-fetch": "^2.7.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.7",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+ "dev": true,
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-abi": {
+ "version": "3.85.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
+ "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
+ "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/otpauth": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.4.1.tgz",
+ "integrity": "sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==",
+ "dependencies": {
+ "@noble/hashes": "1.8.0"
+ },
+ "funding": {
+ "url": "https://github.com/hectorm/otpauth?sponsor=1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
+ },
+ "node_modules/pino": {
+ "version": "9.14.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+ "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-http": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz",
+ "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==",
+ "dependencies": {
+ "get-caller-file": "^2.0.5",
+ "pino": "^9.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
+ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="
+ },
+ "node_modules/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ]
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+ "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
+ },
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
+ "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+ "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
+ "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..7a347ab
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "comfyui-frontend",
+ "version": "1.0.0",
+ "description": "Frontend service for ComfyUI image-to-video generation",
+ "type": "module",
+ "main": "dist/index.js",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@simplewebauthn/server": "^10.0.1",
+ "argon2": "^0.41.1",
+ "better-sqlite3": "^11.6.0",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "express-session": "^1.18.1",
+ "helmet": "^8.0.0",
+ "otpauth": "^9.3.5",
+ "pino": "^9.6.0",
+ "pino-http": "^10.4.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@types/better-sqlite3": "^7.6.12",
+ "@types/express": "^5.0.0",
+ "@types/express-session": "^1.18.1",
+ "@types/node": "^22.10.5",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+}
diff --git a/frontend/public/css/style.css b/frontend/public/css/style.css
new file mode 100644
index 0000000..00a576e
--- /dev/null
+++ b/frontend/public/css/style.css
@@ -0,0 +1,624 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+:root {
+ --primary: #667eea;
+ --primary-dark: #5a6fd6;
+ --secondary: #764ba2;
+ --success: #28a745;
+ --danger: #dc3545;
+ --warning: #ffc107;
+ --gray-100: #f8f9fa;
+ --gray-200: #e9ecef;
+ --gray-300: #dee2e6;
+ --gray-400: #ced4da;
+ --gray-500: #adb5bd;
+ --gray-600: #6c757d;
+ --gray-700: #495057;
+ --gray-800: #343a40;
+ --gray-900: #212529;
+ --radius: 8px;
+ --shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.15);
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: var(--gray-100);
+ color: var(--gray-800);
+ min-height: 100vh;
+}
+
+.hidden { display: none !important; }
+
+/* Auth Pages */
+.auth-container {
+ max-width: 400px;
+ margin: 100px auto;
+ padding: 40px;
+ background: white;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-lg);
+}
+
+.auth-header {
+ text-align: center;
+ margin-bottom: 30px;
+}
+
+.auth-header h1 {
+ font-size: 24px;
+ color: var(--gray-800);
+ margin-bottom: 8px;
+}
+
+.auth-header p {
+ color: var(--gray-600);
+}
+
+.auth-form .form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 6px;
+ font-weight: 500;
+ color: var(--gray-700);
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--gray-300);
+ border-radius: var(--radius);
+ font-size: 14px;
+ transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.form-group input:focus,
+.form-group textarea:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 15px;
+}
+
+/* Buttons */
+.btn {
+ padding: 12px 20px;
+ border: none;
+ border-radius: var(--radius);
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+.btn-secondary {
+ background: var(--gray-600);
+ color: white;
+}
+
+.btn-secondary:hover {
+ background: var(--gray-700);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+}
+
+.btn-link {
+ background: none;
+ color: var(--primary);
+ padding: 8px;
+}
+
+.btn-link:hover {
+ text-decoration: underline;
+}
+
+.btn-block {
+ width: 100%;
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+/* Error/Status Messages */
+.error-message {
+ color: var(--danger);
+ padding: 10px;
+ text-align: center;
+ font-size: 14px;
+}
+
+.status-message {
+ padding: 15px;
+ border-radius: var(--radius);
+ margin-bottom: 15px;
+}
+
+.status-message.info {
+ background: #d1ecf1;
+ color: #0c5460;
+}
+
+.status-message.success {
+ background: #d4edda;
+ color: #155724;
+}
+
+.status-message.error {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+/* MFA */
+.mfa-section {
+ margin-bottom: 20px;
+}
+
+.mfa-section + .mfa-section {
+ padding-top: 20px;
+ border-top: 1px solid var(--gray-200);
+}
+
+/* Navbar */
+.navbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px 30px;
+ background: white;
+ box-shadow: var(--shadow);
+}
+
+.navbar-brand {
+ font-size: 20px;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--primary), var(--secondary));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.navbar-menu {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+.nav-link {
+ color: var(--gray-600);
+ text-decoration: none;
+ padding: 8px 12px;
+ border-radius: var(--radius);
+ transition: all 0.2s;
+}
+
+.nav-link:hover,
+.nav-link.active {
+ color: var(--primary);
+ background: var(--gray-100);
+}
+
+.navbar-user {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-left: 20px;
+ border-left: 1px solid var(--gray-200);
+}
+
+#current-user {
+ font-weight: 500;
+ color: var(--gray-700);
+}
+
+/* Main Content */
+.main-content {
+ padding: 30px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.content-section {
+ animation: fadeIn 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.section-header h2 {
+ font-size: 24px;
+ color: var(--gray-800);
+}
+
+/* Cards */
+.card {
+ background: white;
+ border-radius: var(--radius);
+ padding: 24px;
+ box-shadow: var(--shadow);
+}
+
+.card h2 {
+ font-size: 16px;
+ color: var(--gray-700);
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+ border-bottom: 2px solid var(--primary);
+}
+
+.section-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+}
+
+.section-grid .full-width {
+ grid-column: 1 / -1;
+}
+
+/* File Upload */
+.file-upload {
+ position: relative;
+}
+
+.file-upload input[type="file"] {
+ display: none;
+}
+
+.file-upload-label {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 40px;
+ border: 2px dashed var(--gray-300);
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.file-upload-label:hover {
+ border-color: var(--primary);
+ background: var(--gray-100);
+}
+
+.file-upload.has-file .file-upload-label {
+ display: none;
+}
+
+.upload-icon {
+ font-size: 48px;
+ color: var(--gray-400);
+ margin-bottom: 10px;
+}
+
+.upload-text {
+ color: var(--gray-600);
+}
+
+.preview-image {
+ max-width: 100%;
+ max-height: 300px;
+ border-radius: var(--radius);
+ object-fit: contain;
+}
+
+/* Output */
+.output-video {
+ width: 100%;
+ max-height: 500px;
+ border-radius: var(--radius);
+ background: var(--gray-900);
+}
+
+/* Gallery */
+.gallery-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+}
+
+.gallery-item {
+ background: white;
+ border-radius: var(--radius);
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ transition: transform 0.2s;
+}
+
+.gallery-item:hover {
+ transform: translateY(-4px);
+}
+
+.gallery-item-media {
+ position: relative;
+ aspect-ratio: 16/9;
+ background: var(--gray-900);
+}
+
+.gallery-item-media video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.gallery-item-status {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.gallery-item-status.completed { background: var(--success); color: white; }
+.gallery-item-status.processing { background: var(--warning); color: var(--gray-900); }
+.gallery-item-status.pending { background: var(--gray-500); color: white; }
+.gallery-item-status.failed { background: var(--danger); color: white; }
+
+.gallery-item-info {
+ padding: 15px;
+}
+
+.gallery-item-prompt {
+ font-size: 13px;
+ color: var(--gray-700);
+ margin-bottom: 10px;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.gallery-item-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ color: var(--gray-500);
+}
+
+.gallery-item-actions {
+ display: flex;
+ gap: 8px;
+}
+
+/* Admin */
+.admin-tabs {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+
+.tab-btn {
+ padding: 10px 20px;
+ border: none;
+ background: var(--gray-200);
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.tab-btn.active {
+ background: var(--primary);
+ color: white;
+}
+
+.admin-tab {
+ animation: fadeIn 0.3s ease;
+}
+
+.users-list {
+ background: white;
+ border-radius: var(--radius);
+ overflow: hidden;
+ box-shadow: var(--shadow);
+}
+
+.user-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px 20px;
+ border-bottom: 1px solid var(--gray-200);
+}
+
+.user-item:last-child {
+ border-bottom: none;
+}
+
+.user-info h4 {
+ font-size: 14px;
+ margin-bottom: 4px;
+}
+
+.user-info p {
+ font-size: 12px;
+ color: var(--gray-500);
+}
+
+.user-badges {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+.badge {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.badge-admin { background: var(--primary); color: white; }
+.badge-inactive { background: var(--danger); color: white; }
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-content {
+ background: white;
+ border-radius: var(--radius);
+ padding: 30px;
+ max-width: 500px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: var(--shadow-lg);
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.modal-header h3 {
+ font-size: 18px;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ font-size: 24px;
+ cursor: pointer;
+ color: var(--gray-500);
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid var(--gray-200);
+}
+
+/* Pagination */
+.pagination {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 20px;
+}
+
+.pagination button {
+ padding: 8px 12px;
+ border: 1px solid var(--gray-300);
+ background: white;
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+
+.pagination button.active {
+ background: var(--primary);
+ color: white;
+ border-color: var(--primary);
+}
+
+.pagination button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Filter */
+.filter-group select {
+ padding: 8px 12px;
+ border: 1px solid var(--gray-300);
+ border-radius: var(--radius);
+}
+
+/* Loading Spinner */
+.spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid var(--gray-200);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 20px auto;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .section-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .navbar {
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .navbar-menu {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/public/index.html b/frontend/public/index.html
new file mode 100644
index 0000000..41aa37a
--- /dev/null
+++ b/frontend/public/index.html
@@ -0,0 +1,181 @@
+
+
+
+
+
+ ComfyUI Video Generator
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Input Image
+
+
+
+
![Preview]()
+
+
+
+
+
Generation Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js
new file mode 100644
index 0000000..9ce096c
--- /dev/null
+++ b/frontend/public/js/app.js
@@ -0,0 +1,629 @@
+// State
+let currentUser = null;
+let mfaTypes = [];
+let base64Image = '';
+let currentPage = 1;
+let adminCurrentPage = 1;
+
+// DOM Elements
+const pages = {
+ login: document.getElementById('login-page'),
+ mfa: document.getElementById('mfa-page'),
+ main: document.getElementById('main-page'),
+};
+
+const sections = {
+ generate: document.getElementById('generate-section'),
+ gallery: document.getElementById('gallery-section'),
+ admin: document.getElementById('admin-section'),
+};
+
+// API Helper
+async function api(path, options = {}) {
+ const response = await fetch(`/api${path}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(data.error || 'Request failed');
+ }
+
+ return data;
+}
+
+// Page Navigation
+function showPage(pageName) {
+ Object.values(pages).forEach(p => p.classList.add('hidden'));
+ pages[pageName]?.classList.remove('hidden');
+}
+
+function showSection(sectionName) {
+ Object.values(sections).forEach(s => s.classList.add('hidden'));
+ sections[sectionName]?.classList.remove('hidden');
+
+ document.querySelectorAll('.nav-link').forEach(link => {
+ link.classList.toggle('active', link.dataset.page === sectionName);
+ });
+
+ if (sectionName === 'gallery') loadGallery();
+ if (sectionName === 'admin') loadUsers();
+}
+
+// Auth
+async function checkAuth() {
+ try {
+ const data = await api('/auth/me');
+ currentUser = data.user;
+ showMainApp();
+ } catch {
+ showPage('login');
+ }
+}
+
+function showMainApp() {
+ showPage('main');
+ document.getElementById('current-user').textContent = currentUser.username;
+
+ if (currentUser.isAdmin) {
+ document.querySelectorAll('.admin-only').forEach(el => el.classList.remove('hidden'));
+ }
+
+ showSection('generate');
+}
+
+// Login
+document.getElementById('login-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = document.getElementById('login-error');
+ errorEl.textContent = '';
+
+ const username = document.getElementById('username').value;
+ const password = document.getElementById('password').value;
+
+ try {
+ const data = await api('/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ });
+
+ if (data.requiresMfa) {
+ mfaTypes = data.mfaTypes;
+ showMfaPage();
+ } else {
+ currentUser = data.user;
+ showMainApp();
+ }
+ } catch (error) {
+ errorEl.textContent = error.message;
+ }
+});
+
+function showMfaPage() {
+ showPage('mfa');
+
+ const totpSection = document.getElementById('totp-section');
+ const webauthnSection = document.getElementById('webauthn-section');
+
+ totpSection.classList.toggle('hidden', !mfaTypes.includes('totp'));
+ webauthnSection.classList.toggle('hidden', !mfaTypes.includes('webauthn'));
+}
+
+// TOTP Verification
+document.getElementById('totp-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = document.getElementById('mfa-error');
+ errorEl.textContent = '';
+
+ const code = document.getElementById('totp-code').value;
+
+ try {
+ const data = await api('/auth/mfa/totp', {
+ method: 'POST',
+ body: JSON.stringify({ code }),
+ });
+
+ currentUser = data.user;
+ showMainApp();
+ } catch (error) {
+ errorEl.textContent = error.message;
+ }
+});
+
+// WebAuthn
+document.getElementById('webauthn-btn').addEventListener('click', async () => {
+ const errorEl = document.getElementById('mfa-error');
+ errorEl.textContent = '';
+
+ try {
+ const options = await api('/auth/mfa/webauthn/challenge', { method: 'POST' });
+
+ const credential = await navigator.credentials.get({
+ publicKey: {
+ ...options,
+ challenge: base64UrlToBuffer(options.challenge),
+ allowCredentials: options.allowCredentials?.map(c => ({
+ ...c,
+ id: base64UrlToBuffer(c.id),
+ })),
+ },
+ });
+
+ const response = {
+ id: credential.id,
+ rawId: bufferToBase64Url(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON),
+ authenticatorData: bufferToBase64Url(credential.response.authenticatorData),
+ signature: bufferToBase64Url(credential.response.signature),
+ },
+ };
+
+ const data = await api('/auth/mfa/webauthn/verify', {
+ method: 'POST',
+ body: JSON.stringify(response),
+ });
+
+ currentUser = data.user;
+ showMainApp();
+ } catch (error) {
+ errorEl.textContent = error.message || 'WebAuthn verification failed';
+ }
+});
+
+// Back to login from MFA
+document.getElementById('mfa-back').addEventListener('click', async () => {
+ await api('/auth/logout', { method: 'POST' });
+ showPage('login');
+});
+
+// Logout
+document.getElementById('logout-btn').addEventListener('click', async () => {
+ await api('/auth/logout', { method: 'POST' });
+ currentUser = null;
+ showPage('login');
+});
+
+// Navigation
+document.querySelectorAll('.nav-link').forEach(link => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ showSection(link.dataset.page);
+ });
+});
+
+// Image Upload
+const imageInput = document.getElementById('image-input');
+const imageUploadArea = document.getElementById('image-upload-area');
+const previewImage = document.getElementById('preview-image');
+
+imageInput.addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ previewImage.src = event.target.result;
+ previewImage.classList.remove('hidden');
+ imageUploadArea.classList.add('has-file');
+ base64Image = event.target.result.split(',')[1];
+ };
+ reader.readAsDataURL(file);
+ }
+});
+
+// Generation
+document.getElementById('generate-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ if (!base64Image) {
+ showStatus('Please upload an image first', 'error');
+ return;
+ }
+
+ const btn = document.getElementById('generate-btn');
+ btn.disabled = true;
+ btn.textContent = 'Generating...';
+
+ const statusEl = document.getElementById('generation-status');
+ const videoEl = document.getElementById('output-video');
+
+ statusEl.className = 'status-message info';
+ statusEl.textContent = 'Submitting job...';
+ statusEl.classList.remove('hidden');
+ videoEl.classList.add('hidden');
+
+ try {
+ const formData = {
+ image: base64Image,
+ prompt: document.getElementById('prompt').value,
+ negativePrompt: document.getElementById('negative-prompt').value,
+ resolution: parseInt(document.getElementById('resolution').value),
+ steps: parseInt(document.getElementById('steps').value),
+ };
+
+ const submitData = await api('/generate', {
+ method: 'POST',
+ body: JSON.stringify(formData),
+ });
+
+ const { jobId, contentId } = submitData;
+ statusEl.textContent = `Job submitted. ID: ${jobId}. Waiting for completion...`;
+
+ // Poll for completion
+ await pollJob(jobId, contentId, statusEl, videoEl);
+
+ } catch (error) {
+ showStatus(error.message, 'error');
+ } finally {
+ btn.disabled = false;
+ btn.textContent = 'Generate Video';
+ }
+});
+
+async function pollJob(jobId, contentId, statusEl, videoEl) {
+ const startTime = Date.now();
+ const maxTime = 10 * 60 * 1000; // 10 minutes
+
+ while (Date.now() - startTime < maxTime) {
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ statusEl.textContent = `Generating... (${elapsed}s elapsed)`;
+
+ try {
+ const status = await api(`/generate/${jobId}/status`);
+
+ if (status.status === 'COMPLETED') {
+ statusEl.className = 'status-message success';
+ statusEl.textContent = 'Generation complete!';
+
+ // Load video
+ videoEl.src = `/api/content/${contentId}/stream`;
+ videoEl.classList.remove('hidden');
+ videoEl.play();
+ return;
+ }
+
+ if (status.status === 'FAILED') {
+ throw new Error(status.error || 'Generation failed');
+ }
+
+ await new Promise(r => setTimeout(r, 5000));
+ } catch (error) {
+ showStatus(error.message, 'error');
+ return;
+ }
+ }
+
+ showStatus('Generation timed out', 'error');
+}
+
+function showStatus(message, type) {
+ const statusEl = document.getElementById('generation-status');
+ statusEl.className = `status-message ${type}`;
+ statusEl.textContent = message;
+ statusEl.classList.remove('hidden');
+}
+
+// Gallery
+async function loadGallery(page = 1) {
+ currentPage = page;
+ const grid = document.getElementById('gallery-grid');
+ const pagination = document.getElementById('gallery-pagination');
+ const status = document.getElementById('status-filter').value;
+
+ grid.innerHTML = '';
+
+ try {
+ const params = new URLSearchParams({ page, limit: 12 });
+ if (status) params.append('status', status);
+
+ const data = await api(`/content?${params}`);
+ renderGallery(grid, data.content);
+ renderPagination(pagination, data.pagination, loadGallery);
+ } catch (error) {
+ grid.innerHTML = `${error.message}
`;
+ }
+}
+
+document.getElementById('status-filter').addEventListener('change', () => loadGallery(1));
+
+function renderGallery(container, items) {
+ if (items.length === 0) {
+ container.innerHTML = 'No content found
';
+ return;
+ }
+
+ container.innerHTML = items.map(item => `
+
+
+
+
${escapeHtml(item.prompt || 'No prompt')}
+
+
+
+ `).join('');
+}
+
+async function deleteContent(id) {
+ if (!confirm('Are you sure you want to delete this content?')) return;
+
+ try {
+ await api(`/content/${id}`, { method: 'DELETE' });
+ loadGallery(currentPage);
+ } catch (error) {
+ alert(error.message);
+ }
+}
+
+// Admin
+document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
+ document.querySelectorAll('.admin-tab').forEach(t => t.classList.add('hidden'));
+
+ btn.classList.add('active');
+ document.getElementById(`${btn.dataset.tab}-tab`).classList.remove('hidden');
+
+ if (btn.dataset.tab === 'all-content') loadAdminGallery();
+ });
+});
+
+async function loadUsers() {
+ const container = document.getElementById('users-list');
+ container.innerHTML = '';
+
+ try {
+ const data = await api('/users');
+ container.innerHTML = data.users.map(user => `
+
+
+
${escapeHtml(user.username)}
+
${escapeHtml(user.email || 'No email')}
+
+ ${user.isAdmin ? 'Admin' : ''}
+ ${!user.isActive ? 'Inactive' : ''}
+
+
+
+
+ ${user.id !== currentUser.id ? `` : ''}
+
+
+ `).join('');
+ } catch (error) {
+ container.innerHTML = `${error.message}
`;
+ }
+}
+
+document.getElementById('add-user-btn').addEventListener('click', () => {
+ showModal(`
+
+
+ `);
+
+ document.getElementById('add-user-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const form = e.target;
+
+ try {
+ await api('/users', {
+ method: 'POST',
+ body: JSON.stringify({
+ username: form.username.value,
+ password: form.password.value,
+ email: form.email.value || null,
+ isAdmin: form.isAdmin.checked,
+ }),
+ });
+ hideModal();
+ loadUsers();
+ } catch (error) {
+ alert(error.message);
+ }
+ });
+});
+
+async function editUser(id) {
+ try {
+ const data = await api(`/users/${id}`);
+ const user = data.user;
+
+ showModal(`
+
+
+ `);
+
+ document.getElementById('edit-user-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const form = e.target;
+
+ try {
+ await api(`/users/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ username: form.username.value,
+ email: form.email.value || null,
+ isAdmin: form.isAdmin.checked,
+ isActive: form.isActive.checked,
+ }),
+ });
+
+ if (form.newPassword.value) {
+ await api(`/users/${id}/reset-password`, {
+ method: 'POST',
+ body: JSON.stringify({ newPassword: form.newPassword.value }),
+ });
+ }
+
+ hideModal();
+ loadUsers();
+ } catch (error) {
+ alert(error.message);
+ }
+ });
+ } catch (error) {
+ alert(error.message);
+ }
+}
+
+async function deleteUser(id) {
+ if (!confirm('Are you sure you want to delete this user?')) return;
+
+ try {
+ await api(`/users/${id}`, { method: 'DELETE' });
+ loadUsers();
+ } catch (error) {
+ alert(error.message);
+ }
+}
+
+async function loadAdminGallery(page = 1) {
+ adminCurrentPage = page;
+ const grid = document.getElementById('admin-gallery-grid');
+ const pagination = document.getElementById('admin-gallery-pagination');
+
+ grid.innerHTML = '';
+
+ try {
+ const data = await api(`/content?page=${page}&limit=12`);
+ renderGallery(grid, data.content);
+ renderPagination(pagination, data.pagination, loadAdminGallery);
+ } catch (error) {
+ grid.innerHTML = `${error.message}
`;
+ }
+}
+
+// Modal
+function showModal(content) {
+ document.getElementById('modal-content').innerHTML = content;
+ document.getElementById('modal-overlay').classList.remove('hidden');
+}
+
+function hideModal() {
+ document.getElementById('modal-overlay').classList.add('hidden');
+}
+
+document.getElementById('modal-overlay').addEventListener('click', (e) => {
+ if (e.target === e.currentTarget) hideModal();
+});
+
+// Pagination
+function renderPagination(container, pagination, loadFn) {
+ const { page, totalPages } = pagination;
+ if (totalPages <= 1) {
+ container.innerHTML = '';
+ return;
+ }
+
+ let html = '';
+ html += ``;
+
+ for (let i = 1; i <= totalPages; i++) {
+ if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
+ html += ``;
+ } else if (i === page - 2 || i === page + 2) {
+ html += '...';
+ }
+ }
+
+ html += ``;
+ container.innerHTML = html;
+}
+
+// Utilities
+function escapeHtml(str) {
+ if (!str) return '';
+ return str.replace(/[&<>"']/g, c => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[c]));
+}
+
+function formatDate(dateStr) {
+ return new Date(dateStr).toLocaleDateString('en-US', {
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
+ });
+}
+
+function base64UrlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
+ const padding = '='.repeat((4 - base64.length % 4) % 4);
+ const binary = atob(base64 + padding);
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer;
+}
+
+function bufferToBase64Url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+// Init
+checkAuth();
diff --git a/frontend/src/config.ts b/frontend/src/config.ts
new file mode 100644
index 0000000..db0b686
--- /dev/null
+++ b/frontend/src/config.ts
@@ -0,0 +1,90 @@
+import { existsSync, mkdirSync } from 'fs';
+import { join } from 'path';
+
+function requireEnv(name: string): string {
+ const value = process.env[name];
+ if (!value) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+ return value;
+}
+
+function optionalEnv(name: string, defaultValue: string): string {
+ return process.env[name] || defaultValue;
+}
+
+function optionalEnvInt(name: string, defaultValue: number): number {
+ const value = process.env[name];
+ if (!value) return defaultValue;
+ const parsed = parseInt(value, 10);
+ if (isNaN(parsed)) return defaultValue;
+ return parsed;
+}
+
+function optionalEnvBool(name: string, defaultValue: boolean): boolean {
+ const value = process.env[name];
+ if (!value) return defaultValue;
+ return value.toLowerCase() === 'true' || value === '1';
+}
+
+const dataDir = optionalEnv('DATA_DIR', './data');
+
+// Ensure data directories exist
+if (!existsSync(dataDir)) {
+ mkdirSync(dataDir, { recursive: true });
+}
+const contentDir = join(dataDir, 'content');
+if (!existsSync(contentDir)) {
+ mkdirSync(contentDir, { recursive: true });
+}
+
+export const config = {
+ // Server
+ nodeEnv: optionalEnv('NODE_ENV', 'development'),
+ port: optionalEnvInt('PORT', 3000),
+ isProduction: optionalEnv('NODE_ENV', 'development') === 'production',
+
+ // Paths
+ dataDir,
+ contentDir,
+ dbPath: join(dataDir, 'app.db'),
+
+ // Session
+ sessionSecret: requireEnv('SESSION_SECRET'),
+ sessionMaxAge: optionalEnvInt('SESSION_MAX_AGE_HOURS', 24) * 60 * 60 * 1000,
+
+ // Initial Admin
+ adminUsername: optionalEnv('ADMIN_USERNAME', 'admin'),
+ adminPassword: optionalEnv('ADMIN_PASSWORD', ''),
+ adminEmail: optionalEnv('ADMIN_EMAIL', ''),
+
+ // RunPod
+ runpod: {
+ apiKey: requireEnv('RUNPOD_API_KEY'),
+ endpointId: requireEnv('RUNPOD_ENDPOINT_ID'),
+ baseUrl: 'https://api.runpod.ai/v2',
+ pollIntervalMs: optionalEnvInt('RUNPOD_POLL_INTERVAL_MS', 5000),
+ maxTimeoutMs: optionalEnvInt('RUNPOD_MAX_TIMEOUT_MS', 600000),
+ },
+
+ // WebAuthn
+ webauthn: {
+ rpId: optionalEnv('WEBAUTHN_RP_ID', 'localhost'),
+ rpName: optionalEnv('WEBAUTHN_RP_NAME', 'ComfyUI Video Generator'),
+ origin: optionalEnv('WEBAUTHN_ORIGIN', 'http://localhost:3000'),
+ },
+
+ // Security
+ encryptionKey: requireEnv('ENCRYPTION_KEY'),
+ trustProxy: optionalEnvBool('TRUST_PROXY', true),
+
+ // Rate Limiting
+ rateLimit: {
+ windowMs: optionalEnvInt('RATE_LIMIT_WINDOW_MS', 60000),
+ maxRequests: optionalEnvInt('RATE_LIMIT_MAX_REQUESTS', 100),
+ },
+ loginRateLimit: {
+ windowMs: optionalEnvInt('LOGIN_RATE_LIMIT_WINDOW_MS', 900000),
+ maxRequests: optionalEnvInt('LOGIN_RATE_LIMIT_MAX', 5),
+ },
+};
diff --git a/frontend/src/db/index.ts b/frontend/src/db/index.ts
new file mode 100644
index 0000000..38ab8e0
--- /dev/null
+++ b/frontend/src/db/index.ts
@@ -0,0 +1,135 @@
+import Database from 'better-sqlite3';
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { config } from '../config.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+let db: Database.Database | null = null;
+
+export function getDb(): Database.Database {
+ if (!db) {
+ throw new Error('Database not initialized. Call initDatabase() first.');
+ }
+ return db;
+}
+
+export function initDatabase(): Database.Database {
+ if (db) {
+ return db;
+ }
+
+ db = new Database(config.dbPath);
+
+ // Enable foreign keys and WAL mode for better performance
+ db.pragma('journal_mode = WAL');
+ db.pragma('foreign_keys = ON');
+
+ // Run migrations
+ runMigrations(db);
+
+ return db;
+}
+
+function runMigrations(database: Database.Database): void {
+ // Ensure migrations table exists
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ applied_at TEXT DEFAULT (datetime('now'))
+ )
+ `);
+
+ // Get applied migrations
+ const appliedMigrations = new Set(
+ database.prepare('SELECT version FROM schema_migrations').all()
+ .map((row: any) => row.version)
+ );
+
+ // Migration files in order
+ const migrations = [
+ { version: 1, file: '001_initial.sql' },
+ ];
+
+ for (const migration of migrations) {
+ if (!appliedMigrations.has(migration.version)) {
+ console.log(`Applying migration ${migration.version}: ${migration.file}`);
+
+ const sql = readFileSync(
+ join(__dirname, 'migrations', migration.file),
+ 'utf-8'
+ );
+
+ database.exec(sql);
+
+ database.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(migration.version);
+
+ console.log(`Migration ${migration.version} applied successfully`);
+ }
+ }
+}
+
+export function closeDatabase(): void {
+ if (db) {
+ db.close();
+ db = null;
+ }
+}
+
+// Type definitions for database rows
+export interface UserRow {
+ id: number;
+ username: string;
+ email: string | null;
+ password_hash: string;
+ is_admin: number;
+ is_active: number;
+ created_at: string;
+ updated_at: string;
+ last_login_at: string | null;
+}
+
+export interface SessionRow {
+ id: string;
+ user_id: number;
+ ip_address: string | null;
+ user_agent: string | null;
+ mfa_verified: number;
+ expires_at: string;
+ created_at: string;
+}
+
+export interface MfaCredentialRow {
+ id: number;
+ user_id: number;
+ type: 'totp' | 'webauthn';
+ name: string | null;
+ totp_secret: string | null;
+ credential_id: string | null;
+ public_key: string | null;
+ counter: number;
+ transports: string | null;
+ is_active: number;
+ created_at: string;
+ last_used_at: string | null;
+}
+
+export interface GeneratedContentRow {
+ id: number;
+ user_id: number;
+ filename: string;
+ original_filename: string | null;
+ prompt: string | null;
+ negative_prompt: string | null;
+ resolution: number | null;
+ steps: number | null;
+ split_step: number | null;
+ runpod_job_id: string | null;
+ file_size: number | null;
+ duration_seconds: number | null;
+ mime_type: string;
+ status: 'pending' | 'processing' | 'completed' | 'failed';
+ error_message: string | null;
+ created_at: string;
+}
diff --git a/frontend/src/db/migrations/001_initial.sql b/frontend/src/db/migrations/001_initial.sql
new file mode 100644
index 0000000..c4752a9
--- /dev/null
+++ b/frontend/src/db/migrations/001_initial.sql
@@ -0,0 +1,76 @@
+-- Users table
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE,
+ password_hash TEXT NOT NULL,
+ is_admin INTEGER DEFAULT 0,
+ is_active INTEGER DEFAULT 1,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ last_login_at TEXT
+);
+
+-- Sessions table
+CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ ip_address TEXT,
+ user_agent TEXT,
+ mfa_verified INTEGER DEFAULT 0,
+ expires_at TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- MFA credentials table (supports both TOTP and WebAuthn)
+CREATE TABLE IF NOT EXISTS mfa_credentials (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('totp', 'webauthn')),
+ name TEXT,
+ totp_secret TEXT,
+ credential_id TEXT,
+ public_key TEXT,
+ counter INTEGER DEFAULT 0,
+ transports TEXT,
+ is_active INTEGER DEFAULT 1,
+ created_at TEXT DEFAULT (datetime('now')),
+ last_used_at TEXT,
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Generated content metadata
+CREATE TABLE IF NOT EXISTS generated_content (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ filename TEXT NOT NULL,
+ original_filename TEXT,
+ prompt TEXT,
+ negative_prompt TEXT,
+ resolution INTEGER,
+ steps INTEGER,
+ split_step INTEGER,
+ runpod_job_id TEXT,
+ file_size INTEGER,
+ duration_seconds REAL,
+ mime_type TEXT DEFAULT 'video/mp4',
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
+ error_message TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+-- Schema migrations tracking
+CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ applied_at TEXT DEFAULT (datetime('now'))
+);
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+CREATE INDEX IF NOT EXISTS idx_mfa_credentials_user_id ON mfa_credentials(user_id);
+CREATE INDEX IF NOT EXISTS idx_generated_content_user_id ON generated_content(user_id);
+CREATE INDEX IF NOT EXISTS idx_generated_content_created_at ON generated_content(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_generated_content_status ON generated_content(status);
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
new file mode 100644
index 0000000..7cd3175
--- /dev/null
+++ b/frontend/src/index.ts
@@ -0,0 +1,137 @@
+import express from 'express';
+import session from 'express-session';
+import helmet from 'helmet';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+import { config } from './config.js';
+import { initDatabase, closeDatabase } from './db/index.js';
+import { createInitialAdmin } from './services/initService.js';
+import { SQLiteSessionStore } from './services/sessionService.js';
+import { apiRateLimiter } from './middleware/rateLimit.js';
+import { errorHandler } from './middleware/errorHandler.js';
+import { logger } from './utils/logger.js';
+
+import authRoutes from './routes/auth.js';
+import userRoutes from './routes/users.js';
+import contentRoutes from './routes/content.js';
+import generateRoutes from './routes/generate.js';
+import healthRoutes from './routes/health.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const app = express();
+
+// Trust proxy (for Cloudflare)
+if (config.trustProxy) {
+ app.set('trust proxy', 1);
+}
+
+// Security headers
+app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ imgSrc: ["'self'", 'data:', 'blob:'],
+ mediaSrc: ["'self'", 'data:', 'blob:'],
+ connectSrc: ["'self'"],
+ },
+ },
+ crossOriginEmbedderPolicy: false,
+}));
+
+// Body parsing
+app.use(express.json({ limit: '15mb' }));
+app.use(express.urlencoded({ extended: true }));
+
+// Session middleware
+const sessionStore = new SQLiteSessionStore();
+
+app.use(session({
+ secret: config.sessionSecret,
+ name: 'sid',
+ resave: false,
+ saveUninitialized: false,
+ store: sessionStore,
+ cookie: {
+ httpOnly: true,
+ secure: config.isProduction,
+ sameSite: 'strict',
+ maxAge: config.sessionMaxAge,
+ },
+}));
+
+// Rate limiting for API routes
+app.use('/api/', apiRateLimiter);
+
+// API routes
+app.use('/api/auth', authRoutes);
+app.use('/api/users', userRoutes);
+app.use('/api/content', contentRoutes);
+app.use('/api/generate', generateRoutes);
+
+// Health check routes (no rate limiting)
+app.use('/health', healthRoutes);
+
+// Static files
+app.use(express.static(join(__dirname, '..', 'public')));
+
+// SPA fallback - serve index.html for all non-API routes
+app.get('*', (req, res, next) => {
+ if (req.path.startsWith('/api/') || req.path.startsWith('/health')) {
+ return next();
+ }
+ res.sendFile(join(__dirname, '..', 'public', 'index.html'));
+});
+
+// Error handler
+app.use(errorHandler);
+
+// Startup
+async function start() {
+ try {
+ logger.info('Starting ComfyUI Frontend Service...');
+
+ // Initialize database
+ initDatabase();
+ logger.info({ dbPath: config.dbPath }, 'Database initialized');
+
+ // Create initial admin user if needed
+ await createInitialAdmin();
+
+ // Start server
+ const server = app.listen(config.port, () => {
+ logger.info({ port: config.port, env: config.nodeEnv }, 'Server started');
+ });
+
+ // Graceful shutdown
+ const shutdown = async (signal: string) => {
+ logger.info({ signal }, 'Shutdown signal received');
+
+ server.close(() => {
+ logger.info('HTTP server closed');
+ sessionStore.close();
+ closeDatabase();
+ logger.info('Database closed');
+ process.exit(0);
+ });
+
+ // Force exit after 10 seconds
+ setTimeout(() => {
+ logger.error('Forced shutdown after timeout');
+ process.exit(1);
+ }, 10000);
+ };
+
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+
+ } catch (error) {
+ logger.error({ error }, 'Failed to start server');
+ process.exit(1);
+ }
+}
+
+start();
diff --git a/frontend/src/middleware/auth.ts b/frontend/src/middleware/auth.ts
new file mode 100644
index 0000000..2346db7
--- /dev/null
+++ b/frontend/src/middleware/auth.ts
@@ -0,0 +1,85 @@
+import type { Request, Response, NextFunction } from 'express';
+import { getDb, type UserRow } from '../db/index.js';
+import type { AuthenticatedRequest, User } from '../types/index.js';
+
+function rowToUser(row: UserRow): User {
+ return {
+ id: row.id,
+ username: row.username,
+ email: row.email,
+ isAdmin: Boolean(row.is_admin),
+ isActive: Boolean(row.is_active),
+ createdAt: new Date(row.created_at),
+ updatedAt: new Date(row.updated_at),
+ lastLoginAt: row.last_login_at ? new Date(row.last_login_at) : null,
+ };
+}
+
+export function requireAuth(req: Request, res: Response, next: NextFunction): void {
+ if (!req.session?.userId) {
+ res.status(401).json({ error: 'Authentication required' });
+ return;
+ }
+
+ // Check if MFA is required but not completed
+ if (req.session.mfaRequired && !req.session.mfaVerified) {
+ res.status(403).json({ error: 'MFA verification required' });
+ return;
+ }
+
+ // Load user from database
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(req.session.userId) as UserRow | undefined;
+
+ if (!row) {
+ req.session.destroy(() => {});
+ res.status(401).json({ error: 'User not found or inactive' });
+ return;
+ }
+
+ (req as AuthenticatedRequest).user = rowToUser(row);
+ next();
+}
+
+export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
+ const authReq = req as AuthenticatedRequest;
+
+ if (!authReq.user) {
+ res.status(401).json({ error: 'Authentication required' });
+ return;
+ }
+
+ if (!authReq.user.isAdmin) {
+ res.status(403).json({ error: 'Admin access required' });
+ return;
+ }
+
+ next();
+}
+
+// Middleware for routes that allow partial auth (pre-MFA)
+export function requirePartialAuth(req: Request, res: Response, next: NextFunction): void {
+ if (!req.session?.userId) {
+ res.status(401).json({ error: 'Authentication required' });
+ return;
+ }
+
+ // Load user from database (even if MFA not completed)
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM users WHERE id = ? AND is_active = 1').get(req.session.userId) as UserRow | undefined;
+
+ if (!row) {
+ req.session.destroy(() => {});
+ res.status(401).json({ error: 'User not found or inactive' });
+ return;
+ }
+
+ (req as AuthenticatedRequest).user = rowToUser(row);
+ next();
+}
+
+// Helper to check if user owns a resource or is admin
+export function canAccessResource(user: User | undefined, resourceUserId: number): boolean {
+ if (!user) return false;
+ return user.isAdmin || user.id === resourceUserId;
+}
diff --git a/frontend/src/middleware/errorHandler.ts b/frontend/src/middleware/errorHandler.ts
new file mode 100644
index 0000000..1f92f2b
--- /dev/null
+++ b/frontend/src/middleware/errorHandler.ts
@@ -0,0 +1,64 @@
+import type { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
+import { logger } from '../utils/logger.js';
+import { ZodError } from 'zod';
+
+export interface AppError extends Error {
+ statusCode?: number;
+ isOperational?: boolean;
+}
+
+export function createError(message: string, statusCode: number = 500): AppError {
+ const error: AppError = new Error(message);
+ error.statusCode = statusCode;
+ error.isOperational = true;
+ return error;
+}
+
+export const errorHandler: ErrorRequestHandler = (
+ err: AppError | Error,
+ req: Request,
+ res: Response,
+ _next: NextFunction
+): void => {
+ // Handle Zod validation errors
+ if (err instanceof ZodError) {
+ const message = err.errors.map(e => e.message).join(', ');
+ res.status(400).json({ error: message });
+ return;
+ }
+
+ // Get status code and message
+ const statusCode = 'statusCode' in err ? err.statusCode || 500 : 500;
+ const isOperational = 'isOperational' in err ? err.isOperational : false;
+
+ // Log error
+ if (statusCode >= 500 || !isOperational) {
+ logger.error({
+ err,
+ method: req.method,
+ url: req.url,
+ statusCode,
+ }, 'Unhandled error');
+ } else {
+ logger.warn({
+ message: err.message,
+ method: req.method,
+ url: req.url,
+ statusCode,
+ }, 'Operational error');
+ }
+
+ // Send response
+ res.status(statusCode).json({
+ error: isOperational ? err.message : 'Internal server error',
+ });
+};
+
+// Async route handler wrapper
+export function asyncHandler(
+ fn: (req: Request, res: Response, next: NextFunction) => Promise
+): (req: Request, res: Response, next: NextFunction) => void {
+ return (req, res, next) => {
+ Promise.resolve(fn(req, res, next)).catch(next);
+ };
+}
diff --git a/frontend/src/middleware/rateLimit.ts b/frontend/src/middleware/rateLimit.ts
new file mode 100644
index 0000000..f0b4d69
--- /dev/null
+++ b/frontend/src/middleware/rateLimit.ts
@@ -0,0 +1,54 @@
+import rateLimit from 'express-rate-limit';
+import { config } from '../config.js';
+
+// General API rate limiter
+export const apiRateLimiter = rateLimit({
+ windowMs: config.rateLimit.windowMs,
+ max: config.rateLimit.maxRequests,
+ message: { error: 'Too many requests, please try again later' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ // Use CF-Connecting-IP if behind Cloudflare, otherwise use IP
+ return (req.headers['cf-connecting-ip'] as string) || req.ip || 'unknown';
+ },
+});
+
+// Strict rate limiter for login attempts
+export const loginRateLimiter = rateLimit({
+ windowMs: config.loginRateLimit.windowMs,
+ max: config.loginRateLimit.maxRequests,
+ message: { error: 'Too many login attempts, please try again later' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ return (req.headers['cf-connecting-ip'] as string) || req.ip || 'unknown';
+ },
+ skipSuccessfulRequests: false,
+});
+
+// MFA verification rate limiter
+export const mfaRateLimiter = rateLimit({
+ windowMs: 5 * 60 * 1000, // 5 minutes
+ max: 5,
+ message: { error: 'Too many MFA attempts, please try again later' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ // Use session ID for MFA attempts (per-session limiting)
+ return req.session?.id || req.ip || 'unknown';
+ },
+});
+
+// Generation rate limiter (more restrictive)
+export const generationRateLimiter = rateLimit({
+ windowMs: 60 * 60 * 1000, // 1 hour
+ max: 10,
+ message: { error: 'Generation limit reached, please try again later' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => {
+ // Use user ID for generation limits
+ return req.session?.userId?.toString() || req.ip || 'unknown';
+ },
+});
diff --git a/frontend/src/routes/auth.ts b/frontend/src/routes/auth.ts
new file mode 100644
index 0000000..420e18c
--- /dev/null
+++ b/frontend/src/routes/auth.ts
@@ -0,0 +1,207 @@
+import { Router } from 'express';
+import { verifyUserPassword } from '../services/userService.js';
+import {
+ userHasMfa,
+ getUserMfaTypes,
+ verifyTotpCode,
+ startWebAuthnAuthentication,
+ verifyWebAuthnAuthentication,
+} from '../services/mfaService.js';
+import { requireAuth, requirePartialAuth } from '../middleware/auth.js';
+import { loginRateLimiter, mfaRateLimiter } from '../middleware/rateLimit.js';
+import { asyncHandler } from '../middleware/errorHandler.js';
+import { validateRequest, loginSchema, totpVerifySchema } from '../utils/validators.js';
+import { logger } from '../utils/logger.js';
+import type { AuthenticatedRequest } from '../types/index.js';
+
+const router = Router();
+
+// Login
+router.post('/login', loginRateLimiter, asyncHandler(async (req, res) => {
+ const validation = validateRequest(loginSchema, req.body);
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const { username, password } = validation.data;
+
+ const user = await verifyUserPassword(username, password);
+ if (!user) {
+ logger.warn({ username }, 'Failed login attempt');
+ res.status(401).json({ error: 'Invalid username or password' });
+ return;
+ }
+
+ // Check if user has MFA enabled
+ const hasMfa = userHasMfa(user.id);
+
+ if (hasMfa) {
+ // Set up partial session for MFA
+ req.session.userId = user.id;
+ req.session.isAdmin = user.isAdmin;
+ req.session.mfaRequired = true;
+ req.session.mfaVerified = false;
+
+ const mfaTypes = getUserMfaTypes(user.id);
+
+ res.json({
+ requiresMfa: true,
+ mfaTypes,
+ });
+ return;
+ }
+
+ // No MFA, complete login
+ req.session.userId = user.id;
+ req.session.isAdmin = user.isAdmin;
+ req.session.mfaRequired = false;
+ req.session.mfaVerified = true;
+
+ logger.info({ userId: user.id, username }, 'User logged in');
+
+ res.json({
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ isAdmin: user.isAdmin,
+ },
+ });
+}));
+
+// Verify TOTP
+router.post('/mfa/totp', mfaRateLimiter, requirePartialAuth, asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+
+ if (!req.session.mfaRequired) {
+ res.status(400).json({ error: 'MFA not required for this session' });
+ return;
+ }
+
+ const validation = validateRequest(totpVerifySchema, req.body);
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const { code } = validation.data;
+ const isValid = await verifyTotpCode(authReq.user!.id, code);
+
+ if (!isValid) {
+ logger.warn({ userId: authReq.user!.id }, 'Failed TOTP verification');
+ res.status(401).json({ error: 'Invalid TOTP code' });
+ return;
+ }
+
+ // Complete MFA verification
+ req.session.mfaVerified = true;
+ req.session.mfaRequired = false;
+
+ logger.info({ userId: authReq.user!.id }, 'TOTP verification successful');
+
+ res.json({
+ user: {
+ id: authReq.user!.id,
+ username: authReq.user!.username,
+ email: authReq.user!.email,
+ isAdmin: authReq.user!.isAdmin,
+ },
+ });
+}));
+
+// Get WebAuthn challenge
+router.post('/mfa/webauthn/challenge', mfaRateLimiter, requirePartialAuth, asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+
+ if (!req.session.mfaRequired) {
+ res.status(400).json({ error: 'MFA not required for this session' });
+ return;
+ }
+
+ const options = await startWebAuthnAuthentication(authReq.user!.id);
+
+ // Store challenge in session
+ req.session.webauthnChallenge = options.challenge;
+
+ res.json(options);
+}));
+
+// Verify WebAuthn
+router.post('/mfa/webauthn/verify', mfaRateLimiter, requirePartialAuth, asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+
+ if (!req.session.mfaRequired) {
+ res.status(400).json({ error: 'MFA not required for this session' });
+ return;
+ }
+
+ if (!req.session.webauthnChallenge) {
+ res.status(400).json({ error: 'No WebAuthn challenge found. Request a challenge first.' });
+ return;
+ }
+
+ const isValid = await verifyWebAuthnAuthentication(
+ authReq.user!.id,
+ req.body,
+ req.session.webauthnChallenge
+ );
+
+ // Clear challenge
+ delete req.session.webauthnChallenge;
+
+ if (!isValid) {
+ logger.warn({ userId: authReq.user!.id }, 'Failed WebAuthn verification');
+ res.status(401).json({ error: 'WebAuthn verification failed' });
+ return;
+ }
+
+ // Complete MFA verification
+ req.session.mfaVerified = true;
+ req.session.mfaRequired = false;
+
+ logger.info({ userId: authReq.user!.id }, 'WebAuthn verification successful');
+
+ res.json({
+ user: {
+ id: authReq.user!.id,
+ username: authReq.user!.username,
+ email: authReq.user!.email,
+ isAdmin: authReq.user!.isAdmin,
+ },
+ });
+}));
+
+// Logout
+router.post('/logout', (req, res) => {
+ const userId = req.session?.userId;
+
+ req.session.destroy((err) => {
+ if (err) {
+ logger.error({ err }, 'Session destruction error');
+ }
+
+ if (userId) {
+ logger.info({ userId }, 'User logged out');
+ }
+
+ res.clearCookie('connect.sid');
+ res.json({ success: true });
+ });
+});
+
+// Get current user
+router.get('/me', requireAuth, (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+
+ res.json({
+ user: {
+ id: authReq.user!.id,
+ username: authReq.user!.username,
+ email: authReq.user!.email,
+ isAdmin: authReq.user!.isAdmin,
+ },
+ });
+});
+
+export default router;
diff --git a/frontend/src/routes/content.ts b/frontend/src/routes/content.ts
new file mode 100644
index 0000000..802692b
--- /dev/null
+++ b/frontend/src/routes/content.ts
@@ -0,0 +1,237 @@
+import { Router } from 'express';
+import { createReadStream, statSync } from 'fs';
+import { requireAuth } from '../middleware/auth.js';
+import { canAccessResource } from '../middleware/auth.js';
+import { asyncHandler } from '../middleware/errorHandler.js';
+import {
+ listContent,
+ getContentById,
+ getContentFilePath,
+ deleteContent,
+} from '../services/contentService.js';
+import { validateRequest, contentListSchema } from '../utils/validators.js';
+import { logger } from '../utils/logger.js';
+import type { AuthenticatedRequest } from '../types/index.js';
+
+const router = Router();
+
+// All routes require auth
+router.use(requireAuth);
+
+// List content
+router.get('/', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const validation = validateRequest(contentListSchema, req.query);
+
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const { page = 1, limit = 20, status, userId } = validation.data;
+
+ // Non-admins can only see their own content
+ const filterUserId = authReq.user!.isAdmin && userId ? userId : authReq.user!.id;
+
+ // Admin can see all content if no userId filter
+ const params = authReq.user!.isAdmin && !userId
+ ? { status, page, limit }
+ : { userId: filterUserId, status, page, limit };
+
+ const { content, total } = listContent(params);
+
+ res.json({
+ content: content.map(c => ({
+ id: c.id,
+ filename: c.filename,
+ prompt: c.prompt,
+ resolution: c.resolution,
+ steps: c.steps,
+ status: c.status,
+ fileSize: c.fileSize,
+ createdAt: c.createdAt,
+ userId: c.userId,
+ })),
+ pagination: {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit),
+ },
+ });
+});
+
+// Get content details
+router.get('/:id', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const contentId = parseInt(req.params.id, 10);
+
+ if (isNaN(contentId)) {
+ res.status(400).json({ error: 'Invalid content ID' });
+ return;
+ }
+
+ const content = getContentById(contentId);
+ if (!content) {
+ res.status(404).json({ error: 'Content not found' });
+ return;
+ }
+
+ if (!canAccessResource(authReq.user, content.userId)) {
+ res.status(403).json({ error: 'Access denied' });
+ return;
+ }
+
+ res.json({
+ content: {
+ id: content.id,
+ userId: content.userId,
+ filename: content.filename,
+ originalFilename: content.originalFilename,
+ prompt: content.prompt,
+ negativePrompt: content.negativePrompt,
+ resolution: content.resolution,
+ steps: content.steps,
+ splitStep: content.splitStep,
+ runpodJobId: content.runpodJobId,
+ fileSize: content.fileSize,
+ mimeType: content.mimeType,
+ status: content.status,
+ errorMessage: content.errorMessage,
+ createdAt: content.createdAt,
+ },
+ });
+});
+
+// Download content file
+router.get('/:id/download', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const contentId = parseInt(req.params.id, 10);
+
+ if (isNaN(contentId)) {
+ res.status(400).json({ error: 'Invalid content ID' });
+ return;
+ }
+
+ const content = getContentById(contentId);
+ if (!content) {
+ res.status(404).json({ error: 'Content not found' });
+ return;
+ }
+
+ if (!canAccessResource(authReq.user, content.userId)) {
+ res.status(403).json({ error: 'Access denied' });
+ return;
+ }
+
+ if (content.status !== 'completed') {
+ res.status(400).json({ error: 'Content not ready for download' });
+ return;
+ }
+
+ const filePath = getContentFilePath(content);
+ if (!filePath) {
+ res.status(404).json({ error: 'Content file not found' });
+ return;
+ }
+
+ const stat = statSync(filePath);
+
+ res.setHeader('Content-Type', content.mimeType);
+ res.setHeader('Content-Length', stat.size);
+ res.setHeader('Content-Disposition', `attachment; filename="${content.filename}"`);
+
+ createReadStream(filePath).pipe(res);
+});
+
+// Stream content (for video playback)
+router.get('/:id/stream', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const contentId = parseInt(req.params.id, 10);
+
+ if (isNaN(contentId)) {
+ res.status(400).json({ error: 'Invalid content ID' });
+ return;
+ }
+
+ const content = getContentById(contentId);
+ if (!content) {
+ res.status(404).json({ error: 'Content not found' });
+ return;
+ }
+
+ if (!canAccessResource(authReq.user, content.userId)) {
+ res.status(403).json({ error: 'Access denied' });
+ return;
+ }
+
+ if (content.status !== 'completed') {
+ res.status(400).json({ error: 'Content not ready' });
+ return;
+ }
+
+ const filePath = getContentFilePath(content);
+ if (!filePath) {
+ res.status(404).json({ error: 'Content file not found' });
+ return;
+ }
+
+ const stat = statSync(filePath);
+ const fileSize = stat.size;
+ const range = req.headers.range;
+
+ if (range) {
+ const parts = range.replace(/bytes=/, '').split('-');
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
+ const chunkSize = end - start + 1;
+
+ res.status(206);
+ res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
+ res.setHeader('Accept-Ranges', 'bytes');
+ res.setHeader('Content-Length', chunkSize);
+ res.setHeader('Content-Type', content.mimeType);
+
+ createReadStream(filePath, { start, end }).pipe(res);
+ } else {
+ res.setHeader('Content-Length', fileSize);
+ res.setHeader('Content-Type', content.mimeType);
+
+ createReadStream(filePath).pipe(res);
+ }
+});
+
+// Delete content
+router.delete('/:id', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const contentId = parseInt(req.params.id, 10);
+
+ if (isNaN(contentId)) {
+ res.status(400).json({ error: 'Invalid content ID' });
+ return;
+ }
+
+ const content = getContentById(contentId);
+ if (!content) {
+ res.status(404).json({ error: 'Content not found' });
+ return;
+ }
+
+ if (!canAccessResource(authReq.user, content.userId)) {
+ res.status(403).json({ error: 'Access denied' });
+ return;
+ }
+
+ const success = deleteContent(contentId);
+
+ if (!success) {
+ res.status(500).json({ error: 'Failed to delete content' });
+ return;
+ }
+
+ logger.info({ userId: authReq.user!.id, contentId }, 'Content deleted');
+
+ res.json({ success: true });
+});
+
+export default router;
diff --git a/frontend/src/routes/generate.ts b/frontend/src/routes/generate.ts
new file mode 100644
index 0000000..c7bf158
--- /dev/null
+++ b/frontend/src/routes/generate.ts
@@ -0,0 +1,174 @@
+import { Router } from 'express';
+import { requireAuth } from '../middleware/auth.js';
+import { generationRateLimiter } from '../middleware/rateLimit.js';
+import { asyncHandler } from '../middleware/errorHandler.js';
+import { validateRequest, generationRequestSchema } from '../utils/validators.js';
+import { submitJob, getJobStatus, pollForCompletion } from '../services/runpodService.js';
+import {
+ createPendingContent,
+ updateContentStatus,
+ saveContentFile,
+ getContentById,
+} from '../services/contentService.js';
+import { logger } from '../utils/logger.js';
+import type { AuthenticatedRequest } from '../types/index.js';
+
+const router = Router();
+
+// All routes require auth
+router.use(requireAuth);
+
+// Submit generation job
+router.post('/', generationRateLimiter, asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const validation = validateRequest(generationRequestSchema, req.body);
+
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const { image, prompt, negativePrompt, resolution, steps, splitStep, timeout } = validation.data;
+
+ // Create pending content record
+ const content = createPendingContent({
+ userId: authReq.user!.id,
+ prompt,
+ negativePrompt,
+ resolution,
+ steps,
+ splitStep,
+ });
+
+ logger.info({
+ userId: authReq.user!.id,
+ contentId: content.id,
+ prompt: prompt.substring(0, 50),
+ }, 'Generation job started');
+
+ try {
+ // Submit job to RunPod
+ const job = await submitJob({
+ image,
+ prompt,
+ negativePrompt,
+ resolution,
+ steps,
+ splitStep,
+ timeout,
+ });
+
+ // Update content with job ID
+ updateContentStatus(content.id, 'processing', { runpodJobId: job.id });
+
+ res.json({
+ contentId: content.id,
+ jobId: job.id,
+ status: job.status,
+ });
+ } catch (error) {
+ logger.error({ error, contentId: content.id }, 'Failed to submit generation job');
+ updateContentStatus(content.id, 'failed', { errorMessage: String(error) });
+
+ res.status(500).json({
+ error: 'Failed to submit generation job',
+ contentId: content.id,
+ });
+ }
+}));
+
+// Poll job status and optionally wait for completion
+router.get('/:jobId/status', asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const { jobId } = req.params;
+ const wait = req.query.wait === 'true';
+
+ try {
+ let status;
+
+ if (wait) {
+ // Long poll - wait for completion
+ status = await pollForCompletion(jobId, 60000, 2000); // 1 minute timeout for long poll
+ } else {
+ status = await getJobStatus(jobId);
+ }
+
+ // If completed, process the output
+ if (status.status === 'COMPLETED' && status.output) {
+ // Find the content record for this job
+ const { getDb } = await import('../db/index.js');
+ const db = getDb();
+ const row = db.prepare(
+ 'SELECT id FROM generated_content WHERE runpod_job_id = ? AND user_id = ?'
+ ).get(jobId, authReq.user!.id) as { id: number } | undefined;
+
+ if (row && status.output.outputs && status.output.outputs.length > 0) {
+ const output = status.output.outputs[0];
+
+ if (output.data) {
+ // Save base64 data to file
+ saveContentFile(row.id, output.data);
+ } else if (output.path) {
+ // File was saved to volume - update status
+ updateContentStatus(row.id, 'completed', { fileSize: output.size });
+ }
+ }
+ } else if (status.status === 'FAILED') {
+ // Update content status to failed
+ const { getDb } = await import('../db/index.js');
+ const db = getDb();
+ const row = db.prepare(
+ 'SELECT id FROM generated_content WHERE runpod_job_id = ? AND user_id = ?'
+ ).get(jobId, authReq.user!.id) as { id: number } | undefined;
+
+ if (row) {
+ updateContentStatus(row.id, 'failed', {
+ errorMessage: status.error || status.output?.error || 'Unknown error',
+ });
+ }
+ }
+
+ res.json({
+ jobId,
+ status: status.status,
+ output: status.output,
+ error: status.error,
+ });
+ } catch (error) {
+ logger.error({ error, jobId }, 'Failed to get job status');
+ res.status(500).json({ error: 'Failed to get job status' });
+ }
+}));
+
+// Get content status by content ID
+router.get('/content/:contentId/status', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const contentId = parseInt(req.params.contentId, 10);
+
+ if (isNaN(contentId)) {
+ res.status(400).json({ error: 'Invalid content ID' });
+ return;
+ }
+
+ const content = getContentById(contentId);
+
+ if (!content) {
+ res.status(404).json({ error: 'Content not found' });
+ return;
+ }
+
+ // Check ownership
+ if (content.userId !== authReq.user!.id && !authReq.user!.isAdmin) {
+ res.status(403).json({ error: 'Access denied' });
+ return;
+ }
+
+ res.json({
+ contentId: content.id,
+ status: content.status,
+ runpodJobId: content.runpodJobId,
+ errorMessage: content.errorMessage,
+ });
+});
+
+export default router;
diff --git a/frontend/src/routes/health.ts b/frontend/src/routes/health.ts
new file mode 100644
index 0000000..62eb776
--- /dev/null
+++ b/frontend/src/routes/health.ts
@@ -0,0 +1,22 @@
+import { Router } from 'express';
+import { getDb } from '../db/index.js';
+
+const router = Router();
+
+// Basic health check
+router.get('/', (req, res) => {
+ res.json({ status: 'ok' });
+});
+
+// Readiness check (verifies DB connection)
+router.get('/ready', (req, res) => {
+ try {
+ const db = getDb();
+ db.prepare('SELECT 1').get();
+ res.json({ status: 'ready', database: 'connected' });
+ } catch (error) {
+ res.status(503).json({ status: 'not ready', database: 'disconnected' });
+ }
+});
+
+export default router;
diff --git a/frontend/src/routes/users.ts b/frontend/src/routes/users.ts
new file mode 100644
index 0000000..0410be9
--- /dev/null
+++ b/frontend/src/routes/users.ts
@@ -0,0 +1,381 @@
+import { Router } from 'express';
+import {
+ createUser,
+ getUserById,
+ getAllUsers,
+ updateUser,
+ updatePassword,
+ deleteUser,
+ usernameExists,
+ emailExists,
+} from '../services/userService.js';
+import {
+ getUserMfaCredentials,
+ generateTotpSecret,
+ verifyAndEnableTotp,
+ startWebAuthnRegistration,
+ completeWebAuthnRegistration,
+ deleteMfaCredential,
+ cancelPendingTotp,
+} from '../services/mfaService.js';
+import { requireAuth, requireAdmin } from '../middleware/auth.js';
+import { asyncHandler, createError } from '../middleware/errorHandler.js';
+import {
+ validateRequest,
+ createUserSchema,
+ updateUserSchema,
+ changePasswordSchema,
+ totpVerifySchema,
+ mfaNameSchema,
+} from '../utils/validators.js';
+import { logger } from '../utils/logger.js';
+import type { AuthenticatedRequest } from '../types/index.js';
+
+const router = Router();
+
+// All routes require admin
+router.use(requireAuth, requireAdmin);
+
+// List all users
+router.get('/', (req, res) => {
+ const users = getAllUsers();
+ res.json({
+ users: users.map(u => ({
+ id: u.id,
+ username: u.username,
+ email: u.email,
+ isAdmin: u.isAdmin,
+ isActive: u.isActive,
+ createdAt: u.createdAt,
+ lastLoginAt: u.lastLoginAt,
+ })),
+ });
+});
+
+// Create user
+router.post('/', asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const validation = validateRequest(createUserSchema, req.body);
+
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const { username, password, email, isAdmin } = validation.data;
+
+ // Check if username exists
+ if (usernameExists(username)) {
+ res.status(409).json({ error: 'Username already exists' });
+ return;
+ }
+
+ // Check if email exists
+ if (email && emailExists(email)) {
+ res.status(409).json({ error: 'Email already exists' });
+ return;
+ }
+
+ const user = await createUser(username, password, email, isAdmin);
+
+ logger.info({ adminId: authReq.user!.id, newUserId: user.id, username }, 'User created by admin');
+
+ res.status(201).json({
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ isAdmin: user.isAdmin,
+ isActive: user.isActive,
+ createdAt: user.createdAt,
+ },
+ });
+}));
+
+// Get user
+router.get('/:id', (req, res) => {
+ const userId = parseInt(req.params.id, 10);
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ const user = getUserById(userId);
+ if (!user) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ const mfaCredentials = getUserMfaCredentials(userId);
+
+ res.json({
+ user: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ isAdmin: user.isAdmin,
+ isActive: user.isActive,
+ createdAt: user.createdAt,
+ lastLoginAt: user.lastLoginAt,
+ },
+ mfaCredentials: mfaCredentials.map(c => ({
+ id: c.id,
+ type: c.type,
+ name: c.name,
+ createdAt: c.createdAt,
+ lastUsedAt: c.lastUsedAt,
+ })),
+ });
+});
+
+// Update user
+router.put('/:id', asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ const validation = validateRequest(updateUserSchema, req.body);
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const existingUser = getUserById(userId);
+ if (!existingUser) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ // Check for conflicts
+ if (validation.data.username && usernameExists(validation.data.username, userId)) {
+ res.status(409).json({ error: 'Username already exists' });
+ return;
+ }
+
+ if (validation.data.email && emailExists(validation.data.email, userId)) {
+ res.status(409).json({ error: 'Email already exists' });
+ return;
+ }
+
+ const user = await updateUser(userId, validation.data);
+
+ logger.info({ adminId: authReq.user!.id, userId }, 'User updated by admin');
+
+ res.json({
+ user: {
+ id: user!.id,
+ username: user!.username,
+ email: user!.email,
+ isAdmin: user!.isAdmin,
+ isActive: user!.isActive,
+ createdAt: user!.createdAt,
+ },
+ });
+}));
+
+// Reset user password
+router.post('/:id/reset-password', asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ const { newPassword } = req.body;
+ if (!newPassword || newPassword.length < 12) {
+ res.status(400).json({ error: 'Password must be at least 12 characters' });
+ return;
+ }
+
+ const user = getUserById(userId);
+ if (!user) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ await updatePassword(userId, newPassword);
+
+ logger.info({ adminId: authReq.user!.id, userId }, 'User password reset by admin');
+
+ res.json({ success: true });
+}));
+
+// Delete user
+router.delete('/:id', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ // Prevent self-deletion
+ if (userId === authReq.user!.id) {
+ res.status(400).json({ error: 'Cannot delete your own account' });
+ return;
+ }
+
+ const user = getUserById(userId);
+ if (!user) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ deleteUser(userId);
+
+ logger.info({ adminId: authReq.user!.id, userId, username: user.username }, 'User deleted by admin');
+
+ res.json({ success: true });
+});
+
+// ==================== MFA Management ====================
+
+// Setup TOTP for user
+router.post('/:id/mfa/totp/setup', asyncHandler(async (req, res) => {
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ const user = getUserById(userId);
+ if (!user) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ const validation = validateRequest(mfaNameSchema, req.body);
+ const name = validation.success ? validation.data.name : 'Default';
+
+ // Cancel any pending TOTP setup
+ cancelPendingTotp(userId);
+
+ const { secret, uri } = await generateTotpSecret(userId, name);
+
+ res.json({
+ secret,
+ uri,
+ message: 'Scan the QR code or enter the secret in your authenticator app, then verify with a code',
+ });
+}));
+
+// Verify and enable TOTP
+router.post('/:id/mfa/totp/verify', asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ const validation = validateRequest(totpVerifySchema, req.body);
+ if (!validation.success) {
+ res.status(400).json({ error: validation.error });
+ return;
+ }
+
+ const isValid = await verifyAndEnableTotp(userId, validation.data.code);
+
+ if (!isValid) {
+ res.status(400).json({ error: 'Invalid TOTP code' });
+ return;
+ }
+
+ logger.info({ adminId: authReq.user!.id, userId }, 'TOTP enabled for user');
+
+ res.json({ success: true, message: 'TOTP enabled successfully' });
+}));
+
+// Start WebAuthn registration
+router.post('/:id/mfa/webauthn/register', asyncHandler(async (req, res) => {
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ const user = getUserById(userId);
+ if (!user) {
+ res.status(404).json({ error: 'User not found' });
+ return;
+ }
+
+ const options = await startWebAuthnRegistration(userId);
+
+ // Store challenge in session
+ req.session.webauthnChallenge = options.challenge;
+
+ res.json(options);
+}));
+
+// Complete WebAuthn registration
+router.post('/:id/mfa/webauthn/complete', asyncHandler(async (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const userId = parseInt(req.params.id, 10);
+
+ if (isNaN(userId)) {
+ res.status(400).json({ error: 'Invalid user ID' });
+ return;
+ }
+
+ if (!req.session.webauthnChallenge) {
+ res.status(400).json({ error: 'No WebAuthn challenge found' });
+ return;
+ }
+
+ const validation = validateRequest(mfaNameSchema, { name: req.body.name });
+ const name = validation.success ? validation.data.name : 'Security Key';
+
+ const success = await completeWebAuthnRegistration(
+ userId,
+ req.body,
+ req.session.webauthnChallenge,
+ name
+ );
+
+ delete req.session.webauthnChallenge;
+
+ if (!success) {
+ res.status(400).json({ error: 'WebAuthn registration failed' });
+ return;
+ }
+
+ logger.info({ adminId: authReq.user!.id, userId }, 'WebAuthn credential registered for user');
+
+ res.json({ success: true, message: 'WebAuthn credential registered successfully' });
+}));
+
+// Delete MFA credential
+router.delete('/:id/mfa/:credentialId', (req, res) => {
+ const authReq = req as AuthenticatedRequest;
+ const userId = parseInt(req.params.id, 10);
+ const credentialId = parseInt(req.params.credentialId, 10);
+
+ if (isNaN(userId) || isNaN(credentialId)) {
+ res.status(400).json({ error: 'Invalid ID' });
+ return;
+ }
+
+ const success = deleteMfaCredential(userId, credentialId);
+
+ if (!success) {
+ res.status(404).json({ error: 'MFA credential not found' });
+ return;
+ }
+
+ logger.info({ adminId: authReq.user!.id, userId, credentialId }, 'MFA credential deleted');
+
+ res.json({ success: true });
+});
+
+export default router;
diff --git a/frontend/src/services/contentService.ts b/frontend/src/services/contentService.ts
new file mode 100644
index 0000000..a6099d6
--- /dev/null
+++ b/frontend/src/services/contentService.ts
@@ -0,0 +1,201 @@
+import { writeFileSync, unlinkSync, existsSync, statSync } from 'fs';
+import { join } from 'path';
+import { randomUUID } from 'crypto';
+import { getDb, type GeneratedContentRow } from '../db/index.js';
+import { config } from '../config.js';
+import type { GeneratedContent } from '../types/index.js';
+import { logger } from '../utils/logger.js';
+
+function rowToContent(row: GeneratedContentRow): GeneratedContent {
+ return {
+ id: row.id,
+ userId: row.user_id,
+ filename: row.filename,
+ originalFilename: row.original_filename,
+ prompt: row.prompt,
+ negativePrompt: row.negative_prompt,
+ resolution: row.resolution,
+ steps: row.steps,
+ splitStep: row.split_step,
+ runpodJobId: row.runpod_job_id,
+ fileSize: row.file_size,
+ durationSeconds: row.duration_seconds,
+ mimeType: row.mime_type,
+ status: row.status,
+ errorMessage: row.error_message,
+ createdAt: new Date(row.created_at),
+ };
+}
+
+export interface CreateContentParams {
+ userId: number;
+ prompt: string;
+ negativePrompt?: string;
+ resolution?: number;
+ steps?: number;
+ splitStep?: number;
+ originalFilename?: string;
+}
+
+export function createPendingContent(params: CreateContentParams): GeneratedContent {
+ const db = getDb();
+ const filename = `${randomUUID()}.mp4`;
+
+ const result = db.prepare(`
+ INSERT INTO generated_content (
+ user_id, filename, original_filename, prompt, negative_prompt,
+ resolution, steps, split_step, status
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')
+ `).run(
+ params.userId,
+ filename,
+ params.originalFilename || null,
+ params.prompt,
+ params.negativePrompt || null,
+ params.resolution || null,
+ params.steps || null,
+ params.splitStep || null
+ );
+
+ const row = db.prepare('SELECT * FROM generated_content WHERE id = ?').get(result.lastInsertRowid) as GeneratedContentRow;
+ return rowToContent(row);
+}
+
+export function updateContentStatus(
+ id: number,
+ status: 'processing' | 'completed' | 'failed',
+ updates?: {
+ runpodJobId?: string;
+ fileSize?: number;
+ errorMessage?: string;
+ }
+): GeneratedContent | null {
+ const db = getDb();
+
+ const setParts = ['status = ?'];
+ const values: (string | number | null)[] = [status];
+
+ if (updates?.runpodJobId !== undefined) {
+ setParts.push('runpod_job_id = ?');
+ values.push(updates.runpodJobId);
+ }
+ if (updates?.fileSize !== undefined) {
+ setParts.push('file_size = ?');
+ values.push(updates.fileSize);
+ }
+ if (updates?.errorMessage !== undefined) {
+ setParts.push('error_message = ?');
+ values.push(updates.errorMessage);
+ }
+
+ values.push(id);
+
+ db.prepare(`UPDATE generated_content SET ${setParts.join(', ')} WHERE id = ?`).run(...values);
+
+ return getContentById(id);
+}
+
+export function saveContentFile(contentId: number, data: Buffer | string): string | null {
+ const content = getContentById(contentId);
+ if (!content) return null;
+
+ const filePath = join(config.contentDir, content.filename);
+
+ try {
+ const buffer = typeof data === 'string' ? Buffer.from(data, 'base64') : data;
+ writeFileSync(filePath, buffer);
+
+ const stats = statSync(filePath);
+ updateContentStatus(contentId, 'completed', { fileSize: stats.size });
+
+ logger.info({ contentId, filename: content.filename, size: stats.size }, 'Content file saved');
+
+ return filePath;
+ } catch (error) {
+ logger.error({ error, contentId }, 'Failed to save content file');
+ updateContentStatus(contentId, 'failed', { errorMessage: 'Failed to save file' });
+ return null;
+ }
+}
+
+export function getContentById(id: number): GeneratedContent | null {
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM generated_content WHERE id = ?').get(id) as GeneratedContentRow | undefined;
+ return row ? rowToContent(row) : null;
+}
+
+export function getContentFilePath(content: GeneratedContent): string | null {
+ const filePath = join(config.contentDir, content.filename);
+ return existsSync(filePath) ? filePath : null;
+}
+
+export interface ListContentParams {
+ userId?: number;
+ status?: string;
+ page?: number;
+ limit?: number;
+}
+
+export function listContent(params: ListContentParams): { content: GeneratedContent[]; total: number } {
+ const db = getDb();
+
+ const whereParts: string[] = [];
+ const values: (string | number)[] = [];
+
+ if (params.userId !== undefined) {
+ whereParts.push('user_id = ?');
+ values.push(params.userId);
+ }
+ if (params.status) {
+ whereParts.push('status = ?');
+ values.push(params.status);
+ }
+
+ const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
+
+ // Get total count
+ const countRow = db.prepare(`SELECT COUNT(*) as count FROM generated_content ${whereClause}`).get(...values) as { count: number };
+ const total = countRow.count;
+
+ // Get paginated results
+ const page = params.page || 1;
+ const limit = params.limit || 20;
+ const offset = (page - 1) * limit;
+
+ const rows = db.prepare(`
+ SELECT * FROM generated_content ${whereClause}
+ ORDER BY created_at DESC LIMIT ? OFFSET ?
+ `).all(...values, limit, offset) as GeneratedContentRow[];
+
+ return {
+ content: rows.map(rowToContent),
+ total,
+ };
+}
+
+export function deleteContent(id: number): boolean {
+ const content = getContentById(id);
+ if (!content) return false;
+
+ // Delete file if exists
+ const filePath = join(config.contentDir, content.filename);
+ if (existsSync(filePath)) {
+ try {
+ unlinkSync(filePath);
+ logger.info({ contentId: id, filename: content.filename }, 'Content file deleted');
+ } catch (error) {
+ logger.error({ error, contentId: id }, 'Failed to delete content file');
+ }
+ }
+
+ // Delete database record
+ const db = getDb();
+ const result = db.prepare('DELETE FROM generated_content WHERE id = ?').run(id);
+ return result.changes > 0;
+}
+
+export function getUserContentCount(userId: number): number {
+ const db = getDb();
+ const row = db.prepare('SELECT COUNT(*) as count FROM generated_content WHERE user_id = ?').get(userId) as { count: number };
+ return row.count;
+}
diff --git a/frontend/src/services/initService.ts b/frontend/src/services/initService.ts
new file mode 100644
index 0000000..b1ec119
--- /dev/null
+++ b/frontend/src/services/initService.ts
@@ -0,0 +1,32 @@
+import { config } from '../config.js';
+import { countUsers, createUser } from './userService.js';
+import { logger } from '../utils/logger.js';
+
+export async function createInitialAdmin(): Promise {
+ const userCount = countUsers();
+
+ if (userCount > 0) {
+ logger.info('Users already exist, skipping initial admin creation');
+ return;
+ }
+
+ if (!config.adminPassword) {
+ logger.warn('No ADMIN_PASSWORD set, skipping initial admin creation');
+ logger.warn('Set ADMIN_PASSWORD environment variable to create the initial admin user');
+ return;
+ }
+
+ try {
+ const admin = await createUser(
+ config.adminUsername,
+ config.adminPassword,
+ config.adminEmail || null,
+ true
+ );
+
+ logger.info({ username: admin.username }, 'Initial admin user created');
+ } catch (error) {
+ logger.error({ error }, 'Failed to create initial admin user');
+ throw error;
+ }
+}
diff --git a/frontend/src/services/mfaService.ts b/frontend/src/services/mfaService.ts
new file mode 100644
index 0000000..ae2b1b8
--- /dev/null
+++ b/frontend/src/services/mfaService.ts
@@ -0,0 +1,330 @@
+import { TOTP, Secret } from 'otpauth';
+import {
+ generateRegistrationOptions,
+ verifyRegistrationResponse,
+ generateAuthenticationOptions,
+ verifyAuthenticationResponse,
+ type VerifiedRegistrationResponse,
+ type VerifiedAuthenticationResponse,
+} from '@simplewebauthn/server';
+import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
+import { getDb, type MfaCredentialRow } from '../db/index.js';
+import { encrypt, decrypt, generateSecureToken } from '../utils/crypto.js';
+import { config } from '../config.js';
+import type { MfaCredential, TotpCredential, WebAuthnCredential } from '../types/index.js';
+
+// Helper to convert row to credential
+function rowToCredential(row: MfaCredentialRow): MfaCredential {
+ const base: MfaCredential = {
+ id: row.id,
+ userId: row.user_id,
+ type: row.type,
+ name: row.name,
+ isActive: Boolean(row.is_active),
+ createdAt: new Date(row.created_at),
+ lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : null,
+ };
+
+ if (row.type === 'webauthn') {
+ return {
+ ...base,
+ type: 'webauthn',
+ credentialId: row.credential_id!,
+ publicKey: row.public_key!,
+ counter: row.counter,
+ transports: row.transports ? JSON.parse(row.transports) : [],
+ } as WebAuthnCredential;
+ }
+
+ return base;
+}
+
+// Check if user has any active MFA credentials
+export function userHasMfa(userId: number): boolean {
+ const db = getDb();
+ const row = db.prepare(
+ 'SELECT id FROM mfa_credentials WHERE user_id = ? AND is_active = 1 LIMIT 1'
+ ).get(userId);
+ return !!row;
+}
+
+// Get all MFA credentials for a user
+export function getUserMfaCredentials(userId: number): MfaCredential[] {
+ const db = getDb();
+ const rows = db.prepare(
+ 'SELECT * FROM mfa_credentials WHERE user_id = ? AND is_active = 1'
+ ).all(userId) as MfaCredentialRow[];
+ return rows.map(rowToCredential);
+}
+
+// Get MFA credential types for a user
+export function getUserMfaTypes(userId: number): ('totp' | 'webauthn')[] {
+ const db = getDb();
+ const rows = db.prepare(
+ 'SELECT DISTINCT type FROM mfa_credentials WHERE user_id = ? AND is_active = 1'
+ ).all(userId) as { type: 'totp' | 'webauthn' }[];
+ return rows.map(r => r.type);
+}
+
+// ==================== TOTP ====================
+
+export async function generateTotpSecret(userId: number, name: string = 'Default'): Promise<{ secret: string; uri: string }> {
+ const secret = new Secret({ size: 20 });
+
+ const db = getDb();
+ const user = db.prepare('SELECT username FROM users WHERE id = ?').get(userId) as { username: string } | undefined;
+ if (!user) {
+ throw new Error('User not found');
+ }
+
+ const totp = new TOTP({
+ issuer: config.webauthn.rpName,
+ label: user.username,
+ algorithm: 'SHA1',
+ digits: 6,
+ period: 30,
+ secret,
+ });
+
+ const encryptedSecret = await encrypt(secret.base32);
+
+ // Store temporarily (not active until verified)
+ db.prepare(`
+ INSERT INTO mfa_credentials (user_id, type, name, totp_secret, is_active)
+ VALUES (?, 'totp', ?, ?, 0)
+ `).run(userId, name, encryptedSecret);
+
+ return {
+ secret: secret.base32,
+ uri: totp.toString(),
+ };
+}
+
+export async function verifyAndEnableTotp(userId: number, code: string): Promise {
+ const db = getDb();
+
+ // Get the unverified TOTP credential
+ const row = db.prepare(`
+ SELECT * FROM mfa_credentials
+ WHERE user_id = ? AND type = 'totp' AND is_active = 0
+ ORDER BY created_at DESC LIMIT 1
+ `).get(userId) as MfaCredentialRow | undefined;
+
+ if (!row || !row.totp_secret) {
+ return false;
+ }
+
+ const decryptedSecret = await decrypt(row.totp_secret);
+ const secret = Secret.fromBase32(decryptedSecret);
+
+ const totp = new TOTP({
+ algorithm: 'SHA1',
+ digits: 6,
+ period: 30,
+ secret,
+ });
+
+ const delta = totp.validate({ token: code, window: 1 });
+ if (delta === null) {
+ return false;
+ }
+
+ // Activate the credential
+ db.prepare('UPDATE mfa_credentials SET is_active = 1 WHERE id = ?').run(row.id);
+ return true;
+}
+
+export async function verifyTotpCode(userId: number, code: string): Promise {
+ const db = getDb();
+
+ const rows = db.prepare(`
+ SELECT * FROM mfa_credentials
+ WHERE user_id = ? AND type = 'totp' AND is_active = 1
+ `).all(userId) as MfaCredentialRow[];
+
+ for (const row of rows) {
+ if (!row.totp_secret) continue;
+
+ const decryptedSecret = await decrypt(row.totp_secret);
+ const secret = Secret.fromBase32(decryptedSecret);
+
+ const totp = new TOTP({
+ algorithm: 'SHA1',
+ digits: 6,
+ period: 30,
+ secret,
+ });
+
+ const delta = totp.validate({ token: code, window: 1 });
+ if (delta !== null) {
+ // Update last used time
+ db.prepare("UPDATE mfa_credentials SET last_used_at = datetime('now') WHERE id = ?").run(row.id);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+// ==================== WebAuthn ====================
+
+export async function startWebAuthnRegistration(userId: number): Promise {
+ const db = getDb();
+ const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(userId) as { id: number; username: string } | undefined;
+ if (!user) {
+ throw new Error('User not found');
+ }
+
+ // Get existing credentials to exclude
+ const existingCredentials = db.prepare(`
+ SELECT credential_id, transports FROM mfa_credentials
+ WHERE user_id = ? AND type = 'webauthn' AND is_active = 1
+ `).all(userId) as { credential_id: string; transports: string | null }[];
+
+ const options = await generateRegistrationOptions({
+ rpName: config.webauthn.rpName,
+ rpID: config.webauthn.rpId,
+ userID: new TextEncoder().encode(user.id.toString()),
+ userName: user.username,
+ attestationType: 'none',
+ excludeCredentials: existingCredentials.map(cred => ({
+ id: cred.credential_id,
+ transports: cred.transports ? JSON.parse(cred.transports) : undefined,
+ })),
+ authenticatorSelection: {
+ residentKey: 'preferred',
+ userVerification: 'preferred',
+ },
+ });
+
+ return options;
+}
+
+export async function completeWebAuthnRegistration(
+ userId: number,
+ response: any,
+ expectedChallenge: string,
+ name: string = 'Security Key'
+): Promise {
+ try {
+ const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({
+ response,
+ expectedChallenge,
+ expectedOrigin: config.webauthn.origin,
+ expectedRPID: config.webauthn.rpId,
+ });
+
+ if (!verification.verified || !verification.registrationInfo) {
+ return false;
+ }
+
+ const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
+
+ const db = getDb();
+ db.prepare(`
+ INSERT INTO mfa_credentials (user_id, type, name, credential_id, public_key, counter, transports, is_active)
+ VALUES (?, 'webauthn', ?, ?, ?, ?, ?, 1)
+ `).run(
+ userId,
+ name,
+ credentialID,
+ Buffer.from(credentialPublicKey).toString('base64'),
+ counter,
+ JSON.stringify([])
+ );
+
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export async function startWebAuthnAuthentication(userId: number): Promise {
+ const db = getDb();
+
+ const credentials = db.prepare(`
+ SELECT credential_id, transports FROM mfa_credentials
+ WHERE user_id = ? AND type = 'webauthn' AND is_active = 1
+ `).all(userId) as { credential_id: string; transports: string | null }[];
+
+ if (credentials.length === 0) {
+ throw new Error('No WebAuthn credentials found');
+ }
+
+ const options = await generateAuthenticationOptions({
+ rpID: config.webauthn.rpId,
+ allowCredentials: credentials.map(cred => ({
+ id: cred.credential_id,
+ transports: cred.transports ? JSON.parse(cred.transports) : undefined,
+ })),
+ userVerification: 'preferred',
+ });
+
+ return options;
+}
+
+export async function verifyWebAuthnAuthentication(
+ userId: number,
+ response: any,
+ expectedChallenge: string
+): Promise {
+ const db = getDb();
+
+ // Find the credential being used
+ const credentialId = response.id;
+ const row = db.prepare(`
+ SELECT * FROM mfa_credentials
+ WHERE user_id = ? AND type = 'webauthn' AND credential_id = ? AND is_active = 1
+ `).get(userId, credentialId) as MfaCredentialRow | undefined;
+
+ if (!row || !row.public_key) {
+ return false;
+ }
+
+ try {
+ const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse({
+ response,
+ expectedChallenge,
+ expectedOrigin: config.webauthn.origin,
+ expectedRPID: config.webauthn.rpId,
+ authenticator: {
+ credentialID: row.credential_id!,
+ credentialPublicKey: Buffer.from(row.public_key, 'base64'),
+ counter: row.counter,
+ transports: row.transports ? JSON.parse(row.transports) : undefined,
+ },
+ });
+
+ if (!verification.verified) {
+ return false;
+ }
+
+ // Update counter and last used time
+ db.prepare(`
+ UPDATE mfa_credentials
+ SET counter = ?, last_used_at = datetime('now')
+ WHERE id = ?
+ `).run(verification.authenticationInfo.newCounter, row.id);
+
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// Delete MFA credential
+export function deleteMfaCredential(userId: number, credentialId: number): boolean {
+ const db = getDb();
+ const result = db.prepare(
+ 'DELETE FROM mfa_credentials WHERE id = ? AND user_id = ?'
+ ).run(credentialId, userId);
+ return result.changes > 0;
+}
+
+// Cancel pending TOTP setup
+export function cancelPendingTotp(userId: number): void {
+ const db = getDb();
+ db.prepare(
+ "DELETE FROM mfa_credentials WHERE user_id = ? AND type = 'totp' AND is_active = 0"
+ ).run(userId);
+}
diff --git a/frontend/src/services/runpodService.ts b/frontend/src/services/runpodService.ts
new file mode 100644
index 0000000..9a4cab2
--- /dev/null
+++ b/frontend/src/services/runpodService.ts
@@ -0,0 +1,80 @@
+import { config } from '../config.js';
+import { logger } from '../utils/logger.js';
+import type { GenerationRequest, RunPodJob, RunPodJobStatus } from '../types/index.js';
+
+const { baseUrl, apiKey, endpointId } = config.runpod;
+
+async function runpodFetch(path: string, options: RequestInit = {}): Promise {
+ const url = `${baseUrl}/${endpointId}${path}`;
+
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`,
+ ...options.headers,
+ },
+ });
+
+ return response;
+}
+
+export async function submitJob(input: GenerationRequest): Promise {
+ logger.info({ prompt: input.prompt?.substring(0, 50) }, 'Submitting job to RunPod');
+
+ const response = await runpodFetch('/run', {
+ method: 'POST',
+ body: JSON.stringify({ input }),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ logger.error({ status: response.status, body: text }, 'RunPod API error');
+ throw new Error(`RunPod API error: ${response.status} ${text}`);
+ }
+
+ const result = await response.json();
+ logger.info({ jobId: result.id, status: result.status }, 'Job submitted');
+
+ return result as RunPodJob;
+}
+
+export async function getJobStatus(jobId: string): Promise {
+ const response = await runpodFetch(`/status/${jobId}`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`RunPod API error: ${response.status} ${text}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export async function pollForCompletion(
+ jobId: string,
+ maxTimeoutMs: number = config.runpod.maxTimeoutMs,
+ pollIntervalMs: number = config.runpod.pollIntervalMs
+): Promise {
+ const startTime = Date.now();
+
+ while (Date.now() - startTime < maxTimeoutMs) {
+ const status = await getJobStatus(jobId);
+
+ if (status.status === 'COMPLETED' || status.status === 'FAILED') {
+ return status;
+ }
+
+ logger.debug({ jobId, status: status.status, elapsed: Date.now() - startTime }, 'Job still in progress');
+
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
+ }
+
+ throw new Error(`Job ${jobId} timed out after ${maxTimeoutMs}ms`);
+}
+
+export async function submitAndWait(input: GenerationRequest): Promise {
+ const job = await submitJob(input);
+ return pollForCompletion(job.id);
+}
diff --git a/frontend/src/services/sessionService.ts b/frontend/src/services/sessionService.ts
new file mode 100644
index 0000000..e0d87a5
--- /dev/null
+++ b/frontend/src/services/sessionService.ts
@@ -0,0 +1,152 @@
+import { getDb, type SessionRow } from '../db/index.js';
+import { generateSessionId } from '../utils/crypto.js';
+import type { Session } from '../types/index.js';
+import { Store } from 'express-session';
+
+function rowToSession(row: SessionRow): Session {
+ return {
+ id: row.id,
+ userId: row.user_id,
+ ipAddress: row.ip_address,
+ userAgent: row.user_agent,
+ mfaVerified: Boolean(row.mfa_verified),
+ expiresAt: new Date(row.expires_at),
+ createdAt: new Date(row.created_at),
+ };
+}
+
+export function createSession(
+ userId: number,
+ expiresInMs: number,
+ ipAddress?: string,
+ userAgent?: string
+): Session {
+ const db = getDb();
+ const id = generateSessionId();
+ const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
+
+ db.prepare(`
+ INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(id, userId, ipAddress || null, userAgent || null, expiresAt);
+
+ const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as SessionRow;
+ return rowToSession(row);
+}
+
+export function getSession(id: string): Session | null {
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as SessionRow | undefined;
+ return row ? rowToSession(row) : null;
+}
+
+export function updateSessionMfaVerified(id: string, verified: boolean): boolean {
+ const db = getDb();
+ const result = db.prepare('UPDATE sessions SET mfa_verified = ? WHERE id = ?').run(verified ? 1 : 0, id);
+ return result.changes > 0;
+}
+
+export function deleteSession(id: string): boolean {
+ const db = getDb();
+ const result = db.prepare('DELETE FROM sessions WHERE id = ?').run(id);
+ return result.changes > 0;
+}
+
+export function deleteUserSessions(userId: number): number {
+ const db = getDb();
+ const result = db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
+ return result.changes;
+}
+
+export function cleanExpiredSessions(): number {
+ const db = getDb();
+ const result = db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
+ return result.changes;
+}
+
+// Express session store implementation
+export class SQLiteSessionStore extends Store {
+ private cleanupInterval: NodeJS.Timeout | null = null;
+
+ constructor() {
+ super();
+ // Clean up expired sessions every hour
+ this.cleanupInterval = setInterval(() => {
+ cleanExpiredSessions();
+ }, 60 * 60 * 1000);
+ }
+
+ get(sid: string, callback: (err: any, session?: any) => void): void {
+ try {
+ const db = getDb();
+ const row = db.prepare(`
+ SELECT * FROM sessions WHERE id = ? AND expires_at > datetime('now')
+ `).get(sid) as SessionRow | undefined;
+
+ if (!row) {
+ return callback(null, null);
+ }
+
+ const session = {
+ cookie: {
+ expires: new Date(row.expires_at),
+ },
+ userId: row.user_id,
+ mfaRequired: false,
+ mfaVerified: Boolean(row.mfa_verified),
+ };
+
+ callback(null, session);
+ } catch (error) {
+ callback(error);
+ }
+ }
+
+ set(sid: string, session: any, callback?: (err?: any) => void): void {
+ try {
+ const db = getDb();
+ const expiresAt = session.cookie?.expires
+ ? new Date(session.cookie.expires).toISOString()
+ : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+ db.prepare(`
+ INSERT OR REPLACE INTO sessions (id, user_id, mfa_verified, expires_at)
+ VALUES (?, ?, ?, ?)
+ `).run(sid, session.userId || 0, session.mfaVerified ? 1 : 0, expiresAt);
+
+ callback?.();
+ } catch (error) {
+ callback?.(error);
+ }
+ }
+
+ destroy(sid: string, callback?: (err?: any) => void): void {
+ try {
+ deleteSession(sid);
+ callback?.();
+ } catch (error) {
+ callback?.(error);
+ }
+ }
+
+ touch(sid: string, session: any, callback?: (err?: any) => void): void {
+ try {
+ const db = getDb();
+ const expiresAt = session.cookie?.expires
+ ? new Date(session.cookie.expires).toISOString()
+ : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+ db.prepare('UPDATE sessions SET expires_at = ? WHERE id = ?').run(expiresAt, sid);
+ callback?.();
+ } catch (error) {
+ callback?.(error);
+ }
+ }
+
+ close(): void {
+ if (this.cleanupInterval) {
+ clearInterval(this.cleanupInterval);
+ this.cleanupInterval = null;
+ }
+ }
+}
diff --git a/frontend/src/services/userService.ts b/frontend/src/services/userService.ts
new file mode 100644
index 0000000..4823b0b
--- /dev/null
+++ b/frontend/src/services/userService.ts
@@ -0,0 +1,178 @@
+import { getDb, type UserRow } from '../db/index.js';
+import { hashPassword, verifyPassword } from '../utils/crypto.js';
+import type { User, UserWithPassword } from '../types/index.js';
+
+function rowToUser(row: UserRow): User {
+ return {
+ id: row.id,
+ username: row.username,
+ email: row.email,
+ isAdmin: Boolean(row.is_admin),
+ isActive: Boolean(row.is_active),
+ createdAt: new Date(row.created_at),
+ updatedAt: new Date(row.updated_at),
+ lastLoginAt: row.last_login_at ? new Date(row.last_login_at) : null,
+ };
+}
+
+function rowToUserWithPassword(row: UserRow): UserWithPassword {
+ return {
+ ...rowToUser(row),
+ passwordHash: row.password_hash,
+ };
+}
+
+export async function createUser(
+ username: string,
+ password: string,
+ email?: string | null,
+ isAdmin: boolean = false
+): Promise {
+ const db = getDb();
+ const passwordHash = await hashPassword(password);
+
+ const result = db.prepare(`
+ INSERT INTO users (username, email, password_hash, is_admin)
+ VALUES (?, ?, ?, ?)
+ `).run(username, email || null, passwordHash, isAdmin ? 1 : 0);
+
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid) as UserRow;
+ return rowToUser(row);
+}
+
+export function getUserById(id: number): User | null {
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as UserRow | undefined;
+ return row ? rowToUser(row) : null;
+}
+
+export function getUserByUsername(username: string): User | null {
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined;
+ return row ? rowToUser(row) : null;
+}
+
+export function getUserByUsernameWithPassword(username: string): UserWithPassword | null {
+ const db = getDb();
+ const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as UserRow | undefined;
+ return row ? rowToUserWithPassword(row) : null;
+}
+
+export function getAllUsers(): User[] {
+ const db = getDb();
+ const rows = db.prepare('SELECT * FROM users ORDER BY created_at DESC').all() as UserRow[];
+ return rows.map(rowToUser);
+}
+
+export function countUsers(): number {
+ const db = getDb();
+ const row = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
+ return row.count;
+}
+
+export async function updateUser(
+ id: number,
+ updates: {
+ username?: string;
+ email?: string | null;
+ isAdmin?: boolean;
+ isActive?: boolean;
+ }
+): Promise {
+ const db = getDb();
+
+ const setParts: string[] = [];
+ const values: (string | number | null)[] = [];
+
+ if (updates.username !== undefined) {
+ setParts.push('username = ?');
+ values.push(updates.username);
+ }
+ if (updates.email !== undefined) {
+ setParts.push('email = ?');
+ values.push(updates.email);
+ }
+ if (updates.isAdmin !== undefined) {
+ setParts.push('is_admin = ?');
+ values.push(updates.isAdmin ? 1 : 0);
+ }
+ if (updates.isActive !== undefined) {
+ setParts.push('is_active = ?');
+ values.push(updates.isActive ? 1 : 0);
+ }
+
+ if (setParts.length === 0) {
+ return getUserById(id);
+ }
+
+ setParts.push("updated_at = datetime('now')");
+ values.push(id);
+
+ db.prepare(`UPDATE users SET ${setParts.join(', ')} WHERE id = ?`).run(...values);
+
+ return getUserById(id);
+}
+
+export async function updatePassword(id: number, newPassword: string): Promise {
+ const db = getDb();
+ const passwordHash = await hashPassword(newPassword);
+
+ const result = db.prepare(`
+ UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?
+ `).run(passwordHash, id);
+
+ return result.changes > 0;
+}
+
+export async function verifyUserPassword(username: string, password: string): Promise {
+ const user = getUserByUsernameWithPassword(username);
+ if (!user || !user.isActive) {
+ return null;
+ }
+
+ const isValid = await verifyPassword(user.passwordHash, password);
+ if (!isValid) {
+ return null;
+ }
+
+ // Update last login time
+ const db = getDb();
+ db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id);
+
+ return {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ isAdmin: user.isAdmin,
+ isActive: user.isActive,
+ createdAt: user.createdAt,
+ updatedAt: user.updatedAt,
+ lastLoginAt: new Date(),
+ };
+}
+
+export function deleteUser(id: number): boolean {
+ const db = getDb();
+ const result = db.prepare('DELETE FROM users WHERE id = ?').run(id);
+ return result.changes > 0;
+}
+
+export function usernameExists(username: string, excludeId?: number): boolean {
+ const db = getDb();
+ if (excludeId) {
+ const row = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, excludeId);
+ return !!row;
+ }
+ const row = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
+ return !!row;
+}
+
+export function emailExists(email: string, excludeId?: number): boolean {
+ const db = getDb();
+ if (excludeId) {
+ const row = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, excludeId);
+ return !!row;
+ }
+ const row = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
+ return !!row;
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..109533b
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,120 @@
+import type { Request } from 'express';
+
+// User types
+export interface User {
+ id: number;
+ username: string;
+ email: string | null;
+ isAdmin: boolean;
+ isActive: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ lastLoginAt: Date | null;
+}
+
+export interface UserWithPassword extends User {
+ passwordHash: string;
+}
+
+// Session types
+export interface Session {
+ id: string;
+ userId: number;
+ ipAddress: string | null;
+ userAgent: string | null;
+ mfaVerified: boolean;
+ expiresAt: Date;
+ createdAt: Date;
+}
+
+// MFA types
+export interface MfaCredential {
+ id: number;
+ userId: number;
+ type: 'totp' | 'webauthn';
+ name: string | null;
+ isActive: boolean;
+ createdAt: Date;
+ lastUsedAt: Date | null;
+}
+
+export interface TotpCredential extends MfaCredential {
+ type: 'totp';
+ totpSecret: string;
+}
+
+export interface WebAuthnCredential extends MfaCredential {
+ type: 'webauthn';
+ credentialId: string;
+ publicKey: string;
+ counter: number;
+ transports: string[];
+}
+
+// Content types
+export interface GeneratedContent {
+ id: number;
+ userId: number;
+ filename: string;
+ originalFilename: string | null;
+ prompt: string | null;
+ negativePrompt: string | null;
+ resolution: number | null;
+ steps: number | null;
+ splitStep: number | null;
+ runpodJobId: string | null;
+ fileSize: number | null;
+ durationSeconds: number | null;
+ mimeType: string;
+ status: 'pending' | 'processing' | 'completed' | 'failed';
+ errorMessage: string | null;
+ createdAt: Date;
+}
+
+// Generation request types
+export interface GenerationRequest {
+ image: string;
+ prompt: string;
+ negativePrompt?: string;
+ resolution?: number;
+ steps?: number;
+ splitStep?: number;
+ timeout?: number;
+}
+
+export interface RunPodJob {
+ id: string;
+ status: 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
+}
+
+export interface RunPodJobStatus extends RunPodJob {
+ output?: {
+ status: string;
+ prompt_id: string;
+ outputs: Array<{
+ type: string;
+ filename: string;
+ data?: string;
+ path?: string;
+ size?: number;
+ }>;
+ error?: string;
+ };
+ error?: string;
+}
+
+// Express session extension
+declare module 'express-session' {
+ interface SessionData {
+ userId?: number;
+ isAdmin?: boolean;
+ mfaRequired?: boolean;
+ mfaVerified?: boolean;
+ webauthnChallenge?: string;
+ }
+}
+
+// Extended Request type
+export interface AuthenticatedRequest extends Request {
+ user?: User;
+}
diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts
new file mode 100644
index 0000000..9252fa4
--- /dev/null
+++ b/frontend/src/utils/crypto.ts
@@ -0,0 +1,81 @@
+import { randomBytes, createCipheriv, createDecipheriv, scrypt } from 'crypto';
+import { promisify } from 'util';
+import argon2 from 'argon2';
+import { config } from '../config.js';
+
+const scryptAsync = promisify(scrypt);
+
+// Password hashing with Argon2id
+export async function hashPassword(password: string): Promise {
+ return argon2.hash(password, {
+ type: argon2.argon2id,
+ memoryCost: 65536,
+ timeCost: 3,
+ parallelism: 4,
+ });
+}
+
+export async function verifyPassword(hash: string, password: string): Promise {
+ try {
+ return await argon2.verify(hash, password);
+ } catch {
+ return false;
+ }
+}
+
+// Generate cryptographically secure random strings
+export function generateSecureToken(length: number = 32): string {
+ return randomBytes(length).toString('hex');
+}
+
+export function generateSessionId(): string {
+ return randomBytes(32).toString('base64url');
+}
+
+// AES-256-GCM encryption for TOTP secrets
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 16;
+const AUTH_TAG_LENGTH = 16;
+
+async function deriveKey(): Promise {
+ const keyHex = config.encryptionKey;
+ if (keyHex.length !== 64) {
+ throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
+ }
+ return Buffer.from(keyHex, 'hex');
+}
+
+export async function encrypt(plaintext: string): Promise {
+ const key = await deriveKey();
+ const iv = randomBytes(IV_LENGTH);
+ const cipher = createCipheriv(ALGORITHM, key, iv);
+
+ let encrypted = cipher.update(plaintext, 'utf8', 'hex');
+ encrypted += cipher.final('hex');
+
+ const authTag = cipher.getAuthTag();
+
+ // Format: iv:authTag:ciphertext (all hex)
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
+}
+
+export async function decrypt(ciphertext: string): Promise {
+ const key = await deriveKey();
+ const parts = ciphertext.split(':');
+
+ if (parts.length !== 3) {
+ throw new Error('Invalid ciphertext format');
+ }
+
+ const iv = Buffer.from(parts[0], 'hex');
+ const authTag = Buffer.from(parts[1], 'hex');
+ const encrypted = parts[2];
+
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
+ decipher.setAuthTag(authTag);
+
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
+ decrypted += decipher.final('utf8');
+
+ return decrypted;
+}
diff --git a/frontend/src/utils/logger.ts b/frontend/src/utils/logger.ts
new file mode 100644
index 0000000..6a8d417
--- /dev/null
+++ b/frontend/src/utils/logger.ts
@@ -0,0 +1,18 @@
+import pino from 'pino';
+import { config } from '../config.js';
+
+export const logger = pino({
+ level: config.isProduction ? 'info' : 'debug',
+ transport: config.isProduction
+ ? undefined
+ : {
+ target: 'pino-pretty',
+ options: {
+ colorize: true,
+ },
+ },
+ redact: {
+ paths: ['password', 'passwordHash', 'totpSecret', 'apiKey', 'sessionSecret'],
+ censor: '[REDACTED]',
+ },
+});
diff --git a/frontend/src/utils/validators.ts b/frontend/src/utils/validators.ts
new file mode 100644
index 0000000..01b0cbc
--- /dev/null
+++ b/frontend/src/utils/validators.ts
@@ -0,0 +1,83 @@
+import { z } from 'zod';
+
+// User validation
+export const usernameSchema = z
+ .string()
+ .min(3, 'Username must be at least 3 characters')
+ .max(32, 'Username must be at most 32 characters')
+ .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens');
+
+export const passwordSchema = z
+ .string()
+ .min(12, 'Password must be at least 12 characters')
+ .max(128, 'Password must be at most 128 characters');
+
+export const emailSchema = z
+ .string()
+ .email('Invalid email address')
+ .optional()
+ .nullable();
+
+// Auth schemas
+export const loginSchema = z.object({
+ username: z.string().min(1, 'Username is required'),
+ password: z.string().min(1, 'Password is required'),
+});
+
+export const totpVerifySchema = z.object({
+ code: z.string().length(6, 'TOTP code must be 6 digits').regex(/^\d+$/, 'TOTP code must be numeric'),
+});
+
+// User management schemas
+export const createUserSchema = z.object({
+ username: usernameSchema,
+ password: passwordSchema,
+ email: emailSchema,
+ isAdmin: z.boolean().optional().default(false),
+});
+
+export const updateUserSchema = z.object({
+ username: usernameSchema.optional(),
+ email: emailSchema,
+ isAdmin: z.boolean().optional(),
+ isActive: z.boolean().optional(),
+});
+
+export const changePasswordSchema = z.object({
+ currentPassword: z.string().min(1, 'Current password is required'),
+ newPassword: passwordSchema,
+});
+
+// Content schemas
+export const contentListSchema = z.object({
+ page: z.coerce.number().int().positive().optional().default(1),
+ limit: z.coerce.number().int().min(1).max(100).optional().default(20),
+ status: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
+ userId: z.coerce.number().int().positive().optional(),
+});
+
+// Generation schemas
+export const generationRequestSchema = z.object({
+ image: z.string().min(1, 'Image is required'),
+ prompt: z.string().min(1, 'Prompt is required').max(2000, 'Prompt is too long'),
+ negativePrompt: z.string().max(2000).optional().default(''),
+ resolution: z.number().int().min(480).max(1080).optional().default(720),
+ steps: z.number().int().min(1).max(50).optional().default(8),
+ splitStep: z.number().int().min(1).max(20).optional().default(4),
+ timeout: z.number().int().min(60).max(600).optional().default(600),
+});
+
+// MFA schemas
+export const mfaNameSchema = z.object({
+ name: z.string().min(1).max(64).optional().default('Default'),
+});
+
+// Validation helper
+export function validateRequest(schema: z.ZodSchema, data: unknown): { success: true; data: T } | { success: false; error: string } {
+ const result = schema.safeParse(data);
+ if (result.success) {
+ return { success: true, data: result.data };
+ }
+ const errorMessage = result.error.errors.map(e => e.message).join(', ');
+ return { success: false, error: errorMessage };
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..c83c533
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}