Release v1.0.0
Some checks failed
CI / Migrations, tests, build, and audit (push) Failing after 10m7s
CI / Production gate and container images (push) Successful in 20m35s

This commit is contained in:
2026-07-31 14:21:43 +08:00
commit 36f466ba11
187 changed files with 98907 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.env
.env.*
!.env.example
!.env.production.example
node_modules
server/node_modules
dist
server/dist
coverage
backups
tmp
*.log
.DS_Store

95
.env.example Normal file
View File

@@ -0,0 +1,95 @@
# Application
NODE_ENV=development
API_HOST=127.0.0.1
API_PORT=8787
API_PUBLIC_ORIGIN=http://127.0.0.1:8787
WEB_ORIGIN=http://127.0.0.1:4173
LOG_LEVEL=info
METRICS_HOST=127.0.0.1
WORKER_METRICS_PORT=9092
WORKER_HEARTBEAT_KEY=frameflow:worker:heartbeat
WORKER_HEARTBEAT_INTERVAL_MS=5000
WORKER_HEARTBEAT_TTL_SECONDS=20
METRICS_TOKEN=
# Security - replace every secret outside local development
JWT_ACCESS_SECRET=replace-with-at-least-32-characters
JWT_REFRESH_SECRET=replace-with-another-32-character-secret
CREDENTIAL_ENCRYPTION_KEY=replace-with-an-independent-32-character-secret
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=30
EMAIL_VERIFICATION_REQUIRED=false
EMAIL_VERIFICATION_TTL_HOURS=24
PASSWORD_RESET_TTL_MINUTES=30
# Verification, password-reset, and workspace-invitation email delivery.
RESEND_API_KEY=
EMAIL_FROM=FrameFlow <noreply@frameflow.local>
# PostgreSQL / Redis
DATABASE_URL=postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow
REDIS_URL=redis://127.0.0.1:63799
# S3-compatible object storage
S3_ENDPOINT=http://127.0.0.1:9000
# Optional browser/provider-facing endpoint. Defaults to S3_ENDPOINT locally.
S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000
S3_REGION=us-east-1
S3_BUCKET=frameflow
S3_ACCESS_KEY=frameflow
S3_SECRET_KEY=frameflow-local-secret
S3_FORCE_PATH_STYLE=true
# AI providers (optional until configured)
OPENAI_API_KEY=
OPENAI_TEXT_MODEL=gpt-5.6-sol
OPENAI_IMAGE_MODEL=gpt-image-2
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
# Agnes OpenAI-compatible gateway (text, image and video)
AGNES_API_KEY=
AGNES_TEXT_MODEL=agnes-2.5-flash
AGNES_IMAGE_MODEL=agnes-image-2.1-flash
AGNES_VIDEO_MODEL=agnes-video-v2.0
AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS=900
AGNES_VIDEO_POLL_INTERVAL_MS=4000
# Generic OpenAI-compatible providers. Each capability is configured independently.
# BASE_URL is the API root including its version path, for example https://api.vendor.com/v1.
GENERIC_TEXT_API_KEY=
GENERIC_TEXT_BASE_URL=
GENERIC_TEXT_MODEL=
GENERIC_IMAGE_API_KEY=
GENERIC_IMAGE_BASE_URL=
GENERIC_IMAGE_MODEL=
GENERIC_VIDEO_API_KEY=
GENERIC_VIDEO_BASE_URL=
GENERIC_VIDEO_MODEL=
GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS=900
GENERIC_VIDEO_POLL_INTERVAL_MS=4000
REPLICATE_API_TOKEN=
REPLICATE_IMAGE_VERSION=
REPLICATE_VIDEO_VERSION=
REPLICATE_LIPSYNC_VERSION=
# Replicate cancels predictions that have not finished within this deadline.
REPLICATE_PREDICTION_DEADLINE_SECONDS=900
# Stripe subscriptions (optional; the UI reports missing configuration)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PRICE_ID=
STRIPE_STUDIO_PRICE_ID=
STRIPE_PORTAL_CONFIGURATION_ID=
BILLING_SUCCESS_URL=
BILLING_CANCEL_URL=
# External publishing connector. Leave both empty until a connector is deployed.
PUBLISHING_CONNECTOR_URL=
PUBLISHING_CONNECTOR_SECRET=
PUBLISHING_CONNECTOR_TIMEOUT_MS=30000
# Media worker
FFMPEG_PATH=ffmpeg
FFPROBE_PATH=ffprobe
WORKER_CONCURRENCY=2
MAX_UPLOAD_BYTES=104857600
PROVIDER_DOWNLOAD_TIMEOUT_MS=180000
PROVIDER_DOWNLOAD_MAX_REDIRECTS=3

108
.env.production.example Normal file
View File

@@ -0,0 +1,108 @@
# Public routing. Terminate TLS in front of HTTP_PORT and preserve the Host header.
APP_HOST=studio.example.com
MEDIA_HOST=media.example.com
WEB_ORIGIN=https://studio.example.com
API_PUBLIC_ORIGIN=https://studio.example.com
S3_PUBLIC_ENDPOINT=https://media.example.com
HTTP_BIND=0.0.0.0
HTTP_PORT=8080
MAX_UPLOAD_SIZE=100m
# Image/version naming and local-only operator ports.
COMPOSE_PROJECT_NAME=frameflow-prod
FRAMEFLOW_VERSION=latest
MINIO_CONSOLE_BIND=127.0.0.1
MINIO_CONSOLE_PORT=9001
PROMETHEUS_BIND=127.0.0.1
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION=30d
# Host directory shared by backup.sh and Node Exporter's textfile collector.
FRAMEFLOW_BACKUP_METRICS_DIR=/var/lib/frameflow/metrics
ALERTMANAGER_BIND=127.0.0.1
ALERTMANAGER_PORT=9093
# Replace with the operator endpoint that receives firing and resolved alerts.
ALERTMANAGER_WEBHOOK_URL=https://alerts.example.com/frameflow-webhook
# Replace every value below before deployment. Keep URL passwords URL-safe.
POSTGRES_DB=frameflow
POSTGRES_USER=frameflow
POSTGRES_PASSWORD=replace_with_strong_postgres_password
DATABASE_URL=postgresql://frameflow:replace_with_strong_postgres_password@postgres:5432/frameflow
REDIS_PASSWORD=replace_with_strong_redis_password
REDIS_URL=redis://default:replace_with_strong_redis_password@redis:6379
JWT_ACCESS_SECRET=replace_with_at_least_32_random_characters_access
JWT_REFRESH_SECRET=replace_with_at_least_32_random_characters_refresh
CREDENTIAL_ENCRYPTION_KEY=replace_with_independent_credential_encryption_key
S3_BUCKET=frameflow
S3_REGION=us-east-1
S3_ACCESS_KEY=replace_with_minio_access_key
S3_SECRET_KEY=replace_with_minio_secret_key
# Metrics stay on the private Compose network. Set a token only when the
# Prometheus scrape configuration is updated to send the same token.
METRICS_TOKEN=
LOG_LEVEL=info
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=30
EMAIL_VERIFICATION_REQUIRED=true
EMAIL_VERIFICATION_TTL_HOURS=24
PASSWORD_RESET_TTL_MINUTES=30
# Used for verification, password-reset, and workspace-invitation emails.
RESEND_API_KEY=replace_with_resend_api_key
EMAIL_FROM=FrameFlow <noreply@example.com>
# AI providers. Missing values fail jobs explicitly and never create fake media.
OPENAI_API_KEY=
OPENAI_TEXT_MODEL=gpt-5.6-sol
OPENAI_IMAGE_MODEL=gpt-image-2
OPENAI_TTS_MODEL=gpt-4o-mini-tts
OPENAI_TTS_VOICE=alloy
# Agnes OpenAI-compatible gateway (text, image and video)
AGNES_API_KEY=
AGNES_TEXT_MODEL=agnes-2.5-flash
AGNES_IMAGE_MODEL=agnes-image-2.1-flash
AGNES_VIDEO_MODEL=agnes-video-v2.0
AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS=900
AGNES_VIDEO_POLL_INTERVAL_MS=4000
# Generic OpenAI-compatible providers. Configure each capability as a complete triple.
# BASE_URL is the API root including its version path, for example https://api.vendor.com/v1.
GENERIC_TEXT_API_KEY=
GENERIC_TEXT_BASE_URL=
GENERIC_TEXT_MODEL=
GENERIC_IMAGE_API_KEY=
GENERIC_IMAGE_BASE_URL=
GENERIC_IMAGE_MODEL=
GENERIC_VIDEO_API_KEY=
GENERIC_VIDEO_BASE_URL=
GENERIC_VIDEO_MODEL=
GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS=900
GENERIC_VIDEO_POLL_INTERVAL_MS=4000
REPLICATE_API_TOKEN=
REPLICATE_IMAGE_VERSION=
REPLICATE_VIDEO_VERSION=
REPLICATE_LIPSYNC_VERSION=
# Sent to Replicate as Cancel-After; valid range is 5 seconds to 24 hours.
REPLICATE_PREDICTION_DEADLINE_SECONDS=900
# Stripe subscriptions. Leave all values empty to keep billing read-only.
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_PRICE_ID=
STRIPE_STUDIO_PRICE_ID=
STRIPE_PORTAL_CONFIGURATION_ID=
BILLING_SUCCESS_URL=https://studio.example.com
BILLING_CANCEL_URL=https://studio.example.com
# Optional platform connector for Douyin, Kuaishou, Bilibili, and Xiaohongshu.
# Configure both values together. The connector must expose the documented /v1 endpoints.
PUBLISHING_CONNECTOR_URL=
PUBLISHING_CONNECTOR_SECRET=
PUBLISHING_CONNECTOR_TIMEOUT_MS=30000
WORKER_CONCURRENCY=2
WORKER_HEARTBEAT_KEY=frameflow:worker:heartbeat
WORKER_HEARTBEAT_INTERVAL_MS=5000
WORKER_HEARTBEAT_TTL_SECONDS=20
MAX_UPLOAD_BYTES=104857600
PROVIDER_DOWNLOAD_TIMEOUT_MS=180000
PROVIDER_DOWNLOAD_MAX_REDIRECTS=3

176
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,176 @@
name: CI
on:
push:
branches:
- main
- master
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: frameflow-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
name: Migrations, tests, build, and audit
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
NODE_ENV: test
WEB_ORIGIN: http://127.0.0.1:4173
DATABASE_URL: postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow
REDIS_URL: redis://127.0.0.1:63799
JWT_ACCESS_SECRET: ci-access-secret-with-at-least-32-characters
JWT_REFRESH_SECRET: ci-refresh-secret-with-at-least-32-characters
EMAIL_VERIFICATION_REQUIRED: "false"
S3_ENDPOINT: http://127.0.0.1:9000
S3_PUBLIC_ENDPOINT: http://127.0.0.1:9000
S3_REGION: us-east-1
S3_BUCKET: frameflow
S3_ACCESS_KEY: frameflow
S3_SECRET_KEY: frameflow-local-secret
S3_FORCE_PATH_STYLE: "true"
FFMPEG_PATH: ffmpeg
FFPROBE_PATH: ffprobe
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install system media dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk
- name: Install dependencies
run: npm ci
- name: Validate production smoke-test CLI
run: npm run smoke:production -- --help
- name: Validate Alertmanager smoke-test CLI
run: npm run smoke:alertmanager -- --help
- name: Validate Stripe smoke-test CLI
run: npm run smoke:stripe -- --help
- name: Validate isolated restore-rehearsal CLI
run: npm run restore:rehearse -- --help
- name: Validate scheduled maintenance CLI and systemd units
run: |
./scripts/scheduled-maintenance.sh --help
systemd-analyze verify deploy/systemd/*.service deploy/systemd/*.timer
- name: Start test infrastructure
run: |
docker compose up -d --wait postgres redis minio
docker compose run --rm --no-deps minio-init
- name: Apply database migrations
run: npm run db:migrate
- name: Run test suite
run: npm test
- name: Create and rehearse a real isolated restore
shell: bash
env:
COMPOSE_PROJECT_NAME: frameflow
FRAMEFLOW_ENV_FILE: ${{ github.workspace }}/.env.production.example
FRAMEFLOW_BACKUP_METRICS_DIR: ${{ runner.temp }}/frameflow-metrics
FRAMEFLOW_RESTORE_METRICS_DIR: ${{ runner.temp }}/frameflow-metrics
run: |
backup_parent="${RUNNER_TEMP}/frameflow-backups"
./scripts/backup.sh "${backup_parent}"
backup_dir="$(find "${backup_parent}" -mindepth 1 -maxdepth 1 -type d -name 'frameflow-*' -print -quit)"
test -n "${backup_dir}"
npm run restore:rehearse -- "${backup_dir}"
- name: Build web and server
run: npm run build:all
- name: Audit production dependencies
run: npm audit --omit=dev
- name: Show infrastructure diagnostics
if: failure()
run: |
docker compose ps
docker compose logs --no-color --tail=200 postgres redis minio
- name: Stop test infrastructure
if: always()
run: docker compose down --volumes --remove-orphans
production-images:
name: Production gate and container images
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Check out source
uses: actions/checkout@v4
- name: Create synthetic production configuration
shell: bash
run: |
production_env="${RUNNER_TEMP}/frameflow-production.env"
{
echo "APP_HOST=studio.frameflow-ci.test"
echo "MEDIA_HOST=media.frameflow-ci.test"
echo "WEB_ORIGIN=https://studio.frameflow-ci.test"
echo "API_PUBLIC_ORIGIN=https://studio.frameflow-ci.test"
echo "S3_PUBLIC_ENDPOINT=https://media.frameflow-ci.test"
echo "COMPOSE_PROJECT_NAME=frameflow-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "FRAMEFLOW_VERSION=ci"
echo "ALERTMANAGER_WEBHOOK_URL=https://alerts.frameflow-ci.test/webhook"
echo "POSTGRES_DB=frameflow"
echo "POSTGRES_USER=frameflow"
echo "POSTGRES_PASSWORD=ci-postgres-password-with-32-characters"
echo "DATABASE_URL=postgresql://frameflow:ci-postgres-password-with-32-characters@postgres:5432/frameflow"
echo "REDIS_PASSWORD=ci-redis-password-with-32-characters"
echo "REDIS_URL=redis://default:ci-redis-password-with-32-characters@redis:6379"
echo "JWT_ACCESS_SECRET=ci-access-signing-secret-with-32-characters"
echo "JWT_REFRESH_SECRET=ci-refresh-signing-secret-with-32-characters"
echo "CREDENTIAL_ENCRYPTION_KEY=ci-credential-encryption-secret-with-32-characters"
echo "S3_BUCKET=frameflow"
echo "S3_ACCESS_KEY=frameflow-ci-access-key"
echo "S3_SECRET_KEY=frameflow-ci-storage-secret-with-32-characters"
echo "EMAIL_VERIFICATION_REQUIRED=true"
echo "RESEND_API_KEY=re_ci_configuration_validation_only"
echo "EMAIL_FROM=FrameFlow CI <noreply@frameflow-ci.test>"
echo "BILLING_SUCCESS_URL=https://studio.frameflow-ci.test"
echo "BILLING_CANCEL_URL=https://studio.frameflow-ci.test"
} > "${production_env}"
echo "PRODUCTION_ENV=${production_env}" >> "${GITHUB_ENV}"
- name: Validate production Compose configuration
run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml config --quiet
- name: Build production images
run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml build config-check web
- name: Accept a safe production configuration
run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml run --rm --no-deps config-check
- name: Reject the placeholder production example
shell: bash
run: |
if COMPOSE_PROJECT_NAME="frameflow-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" FRAMEFLOW_VERSION=ci docker compose --env-file .env.production.example -f docker-compose.prod.yml run --rm --no-deps config-check; then
echo "The production gate accepted placeholder configuration." >&2
exit 1
fi
- name: Remove build containers
if: always()
run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml down --remove-orphans

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
dist/
server/dist/
.env
.env.local
*.log
.DS_Store
coverage/
.turbo/
tmp/
backups/
.env.production

48
Dockerfile.server Normal file
View File

@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1.7
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY server/package.json ./server/package.json
RUN npm ci --workspace @frameflow/server --include-workspace-root=false
COPY server ./server
RUN npm run build --workspace @frameflow/server
FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN DEBIAN_FRONTEND=noninteractive apt-get \
-o Acquire::Retries=3 \
-o Acquire::ForceIPv4=true \
-o Acquire::http::Timeout=30 \
-o Acquire::http::Pipeline-Depth=0 \
-o Acquire::http::No-Cache=true \
-o Acquire::BrokenProxy=true \
-o Acquire::https::Timeout=30 \
update \
&& DEBIAN_FRONTEND=noninteractive apt-get \
-o Acquire::Retries=3 \
-o Acquire::ForceIPv4=true \
-o Acquire::http::Timeout=30 \
-o Acquire::http::Pipeline-Depth=0 \
-o Acquire::http::No-Cache=true \
-o Acquire::BrokenProxy=true \
-o Acquire::https::Timeout=30 \
install -y --no-install-recommends ca-certificates dumb-init ffmpeg fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY server/package.json ./server/package.json
RUN npm ci --omit=dev --workspace @frameflow/server --include-workspace-root=false \
&& npm cache clean --force
COPY --from=build --chown=node:node /app/server/dist ./server/dist
COPY --chown=node:node server/drizzle ./server/drizzle
USER node
EXPOSE 8787 9092
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server/dist/index.js"]

23
Dockerfile.web Normal file
View File

@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1.7
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY server/package.json ./server/package.json
RUN npm ci --include-workspace-root
COPY index.html ./
COPY src ./src
COPY public ./public
ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
FROM nginx:1.29-alpine AS runtime
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \
CMD wget -qO- http://127.0.0.1/health/live >/dev/null || exit 1

37
README.md Normal file
View File

@@ -0,0 +1,37 @@
# FrameFlow AI Comic Studio
FrameFlow is an end-to-end AI comic drama production workspace. It includes project and episode management, reusable character and scene assets, AI script and storyboard jobs, shot production, TTS/video/lipsync processing, FFmpeg rendering with immutable source manifests and SRT/VTT sidecars, immutable versions, review workflows, user-scoped persistent notifications with authenticated realtime updates, email-backed workspace invitations, delivery, usage quotas, persisted administrator-only AI provider connection verification, Prometheus metrics, infrastructure capacity and backup-age alerts, and Alertmanager webhook notifications.
## Local development
Requirements: Node.js 24+, Docker Compose, and FFmpeg.
```bash
cp .env.example .env
npm ci
npm run infra:up
npm run db:migrate
npm run db:seed
npm run dev
```
The web app runs at `http://127.0.0.1:5173` by default. The repository's current preview setup may use port `4173` instead. The API readiness endpoint is `http://127.0.0.1:8787/health/ready`; it also verifies that the generation worker heartbeat is current.
Cloud jobs require valid OpenAI and Replicate configuration. Missing provider credentials cause explicit failed jobs; the system does not substitute generated demo output.
Publishing to Douyin, Kuaishou, Bilibili, and Xiaohongshu requires a separately deployed connector that owns each platform's application credentials and API-specific behavior. FrameFlow stores encrypted channel tokens, schedules delivery jobs, and verifies signed status callbacks. The connector HTTP contract, environment variables, and live acceptance checklist are documented in [docs/deployment.md](docs/deployment.md#publishing-connector).
## Verification
```bash
npm run build:all
npm test
```
The GitHub Actions workflow in `.github/workflows/ci.yml` runs migrations, the complete web and server test suites, a real three-store backup and isolated restore rehearsal, production builds, a production-dependency audit, production Compose validation, container image builds, and both the accepting and rejecting sides of the production configuration gate. Its production values are synthetic and generated only inside the runner; no deploy credentials are stored in the workflow.
After deployment, `npm run smoke:production` runs an authenticated, provider-free production path through persistence, object upload/download, BullMQ, FFmpeg rendering, subtitle sidecars, review approval, audit logs, and secure download delivery. The default mode reports AI provider configuration without starting billable jobs. A separate `--include-ai` mode performs live OpenAI and Replicate generation only after an exact billable-charge acknowledgement, complete seven-workflow readiness, and a 700-credit preflight. Required environment variables, provider-specific parameter handling, and data-retention behavior are documented in the deployment guide.
Production deployment, TLS/media routing, monitoring, controlled firing/resolved Alertmanager webhook acceptance, read-only Stripe resource verification, backup, restore, and upgrade procedures are documented in [docs/deployment.md](docs/deployment.md).
The production Compose stack includes a fail-fast configuration gate before migrations; it rejects placeholder secrets, unsafe public endpoints, and incomplete provider or billing configuration without printing secret values.
Production backups are checksum-protected and pass a standalone read-only archive verifier before success metrics are published. A separate restore-rehearsal command restores all three data stores into randomly named, labeled Docker resources with no published ports, verifies their contents, publishes rehearsal metrics, and removes the isolated resources. Supplied hardened systemd timers run a verified backup daily and require a fresh successful backup before the monthly restore rehearsal; the scheduler refuses to use a missing or unexpected backup mount.

View File

@@ -0,0 +1,22 @@
global:
resolve_timeout: 5m
route:
receiver: frameflow-operator-webhook
group_by: [alertname, severity]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
routes:
- receiver: frameflow-operator-webhook
matchers:
- alertname="FrameFlowDeliveryTest"
group_wait: 1s
group_interval: 5s
repeat_interval: 1h
receivers:
- name: frameflow-operator-webhook
webhook_configs:
- url: "__ALERTMANAGER_WEBHOOK_URL__"
send_resolved: true

156
deploy/alerts.yml Normal file
View File

@@ -0,0 +1,156 @@
groups:
- name: frameflow-availability
rules:
- alert: FrameFlowApiDown
expr: up{job="frameflow-api"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: FrameFlow API is down
description: Prometheus has been unable to scrape the API for two minutes.
- alert: FrameFlowWorkerDown
expr: up{job="frameflow-worker"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: FrameFlow worker is down
description: Prometheus has been unable to scrape the generation worker for two minutes.
- alert: FrameFlowDependencyUnavailable
expr: frameflow_application_dependency_available == 0
for: 2m
labels:
severity: critical
annotations:
summary: FrameFlow dependency is unavailable
description: "API readiness dependency {{ $labels.dependency }} has been unavailable for two minutes."
- alert: FrameFlowWorkerHeartbeatStale
expr: time() - frameflow_generation_worker_heartbeat_timestamp_seconds > 30
for: 1m
labels:
severity: critical
annotations:
summary: FrameFlow worker heartbeat is stale
description: The worker metrics process is reachable but its Redis heartbeat has stopped.
- alert: FrameFlowInfrastructureExporterDown
expr: up{job=~"frameflow-node|frameflow-minio"} == 0
for: 5m
labels:
severity: critical
annotations:
summary: FrameFlow infrastructure metrics are unavailable
description: "Prometheus has been unable to scrape {{ $labels.job }} for five minutes."
- name: frameflow-capacity-and-backup
rules:
- alert: FrameFlowHostFilesystemLow
expr: |
(
node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs|nsfs|tracefs|cgroup2?"}
/
node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs|nsfs|tracefs|cgroup2?"}
) < 0.15
and on(instance, device, mountpoint)
node_filesystem_readonly == 0
for: 15m
labels:
severity: warning
annotations:
summary: FrameFlow host filesystem has less than 15% free space
description: "Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} is running low on space."
- alert: FrameFlowMinioCapacityLow
expr: |
minio_cluster_capacity_usable_free_bytes
/
minio_cluster_capacity_usable_total_bytes
< 0.15
for: 15m
labels:
severity: warning
annotations:
summary: FrameFlow object storage has less than 15% usable capacity
description: "MinIO cluster {{ $labels.server }} is running low on usable object-storage capacity."
- alert: FrameFlowBackupStale
expr: |
(time() - frameflow_backup_last_success_timestamp_seconds > 90000)
or absent(frameflow_backup_last_success_timestamp_seconds)
for: 15m
labels:
severity: critical
annotations:
summary: FrameFlow has no successful backup in the last 25 hours
description: Run and verify the scheduled PostgreSQL, Redis, and MinIO backup.
- alert: FrameFlowRestoreRehearsalStale
expr: |
(time() - frameflow_restore_rehearsal_last_success_timestamp_seconds > 3024000)
or absent(frameflow_restore_rehearsal_last_success_timestamp_seconds)
for: 15m
labels:
severity: warning
annotations:
summary: FrameFlow has no successful restore rehearsal in the last 35 days
description: Restore a verified backup into the isolated rehearsal environment and investigate any validation failure.
- name: frameflow-performance
rules:
- alert: FrameFlowApiHighErrorRate
expr: |
(
sum(rate(frameflow_api_requests_total{status=~"5.."}[5m]))
/
clamp_min(sum(rate(frameflow_api_requests_total[5m])), 0.001)
) > 0.05
and sum(rate(frameflow_api_requests_total[5m])) > 0.05
for: 10m
labels:
severity: warning
annotations:
summary: FrameFlow API error rate is high
description: More than 5% of API requests have returned 5xx responses for ten minutes.
- alert: FrameFlowApiLatencyHigh
expr: |
histogram_quantile(0.95,
sum by (le) (rate(frameflow_api_request_duration_seconds_bucket[10m]))
) > 2
for: 10m
labels:
severity: warning
annotations:
summary: FrameFlow API latency is high
description: API p95 latency has exceeded two seconds for ten minutes.
- alert: FrameFlowGenerationFailureRateHigh
expr: |
(
sum(rate(frameflow_generation_jobs_processed_total{result="failed"}[10m]))
/
clamp_min(sum(rate(frameflow_generation_jobs_processed_total[10m])), 0.001)
) > 0.20
and sum(rate(frameflow_generation_jobs_processed_total[10m])) > 0.01
for: 10m
labels:
severity: warning
annotations:
summary: FrameFlow generation failure rate is high
description: More than 20% of recent generation attempts have failed.
- alert: FrameFlowQueueLatencyHigh
expr: |
histogram_quantile(0.95,
sum by (le) (rate(frameflow_generation_job_queue_delay_seconds_bucket[10m]))
) > 60
for: 10m
labels:
severity: warning
annotations:
summary: FrameFlow queue latency is high
description: Generation job p95 queue delay has exceeded 60 seconds for ten minutes.

View File

@@ -0,0 +1,83 @@
upstream frameflow_api {
server api:8787;
keepalive 32;
}
server {
listen 80 default_server;
server_name ${APP_HOST};
root /usr/share/nginx/html;
index index.html;
client_max_body_size ${MAX_UPLOAD_SIZE};
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always;
location /api/ {
proxy_pass http://frameflow_api;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location /health/ {
proxy_pass http://frameflow_api;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /metrics {
return 404;
}
location ~* \.(?:css|js|jpg|jpeg|png|webp|avif|svg|ico|woff2?)$ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
server {
listen 80;
server_name ${MEDIA_HOST};
client_max_body_size ${MAX_UPLOAD_SIZE};
location = /minio/v2/metrics {
return 404;
}
location ^~ /minio/v2/metrics/ {
return 404;
}
location = /minio/v3/metrics {
return 404;
}
location ^~ /minio/v3/metrics/ {
return 404;
}
location / {
proxy_pass http://minio:9000;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

32
deploy/prometheus.yml Normal file
View File

@@ -0,0 +1,32 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
- job_name: frameflow-api
metrics_path: /metrics
static_configs:
- targets: ["api:8787"]
- job_name: frameflow-worker
metrics_path: /metrics
static_configs:
- targets: ["worker:9092"]
- job_name: frameflow-node
metrics_path: /metrics
static_configs:
- targets: ["node-exporter:9100"]
- job_name: frameflow-minio
metrics_path: /minio/v2/metrics/cluster
static_configs:
- targets: ["minio:9000"]

View File

@@ -0,0 +1,9 @@
# Copy to /etc/frameflow/backup.env and keep it readable only by root and the
# FrameFlow service group. These paths must match the installed application,
# encrypted backup mount, and Node Exporter textfile collector configuration.
FRAMEFLOW_ENV_FILE=/srv/frameflow/.env.production
FRAMEFLOW_BACKUP_MOUNTPOINT=/mnt/encrypted-backups
FRAMEFLOW_BACKUP_DESTINATION=/mnt/encrypted-backups/frameflow
FRAMEFLOW_BACKUP_METRICS_DIR=/var/lib/frameflow/metrics
FRAMEFLOW_RESTORE_METRICS_DIR=/var/lib/frameflow/metrics
FRAMEFLOW_RESTORE_TIMEOUT_SECONDS=300

View File

@@ -0,0 +1,35 @@
[Unit]
Description=FrameFlow verified PostgreSQL, Redis, and MinIO backup
Documentation=file:///srv/frameflow/docs/deployment.md
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
RequiresMountsFor=/mnt/encrypted-backups
AssertPathIsMountPoint=/mnt/encrypted-backups
ConditionPathExists=/srv/frameflow/scripts/scheduled-maintenance.sh
ConditionPathExists=/etc/frameflow/backup.env
[Service]
Type=oneshot
User=frameflow
Group=frameflow
SupplementaryGroups=docker
EnvironmentFile=/etc/frameflow/backup.env
WorkingDirectory=/srv/frameflow
ExecStart=/srv/frameflow/scripts/scheduled-maintenance.sh backup
UMask=0077
TimeoutStartSec=6h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectControlGroups=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
ReadWritePaths=/mnt/encrypted-backups/frameflow /var/lib/frameflow/metrics /var/run/docker.sock

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Run the FrameFlow verified backup every day
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
AccuracySec=1m
Persistent=true
Unit=frameflow-backup.service
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,36 @@
[Unit]
Description=FrameFlow isolated restore rehearsal of the latest verified backup
Documentation=file:///srv/frameflow/docs/deployment.md
Requires=docker.service frameflow-backup.service
After=docker.service frameflow-backup.service network-online.target
Wants=network-online.target
RequiresMountsFor=/mnt/encrypted-backups
AssertPathIsMountPoint=/mnt/encrypted-backups
ConditionPathExists=/srv/frameflow/scripts/scheduled-maintenance.sh
ConditionPathExists=/etc/frameflow/backup.env
[Service]
Type=oneshot
User=frameflow
Group=frameflow
SupplementaryGroups=docker
EnvironmentFile=/etc/frameflow/backup.env
WorkingDirectory=/srv/frameflow
ExecStart=/srv/frameflow/scripts/scheduled-maintenance.sh restore-latest
UMask=0077
TimeoutStartSec=6h
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectControlGroups=true
ProtectKernelModules=true
ProtectKernelTunables=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
ReadOnlyPaths=/mnt/encrypted-backups/frameflow
ReadWritePaths=/var/lib/frameflow/metrics /var/run/docker.sock

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Run a FrameFlow isolated restore rehearsal every month
[Timer]
OnCalendar=*-*-01 05:15:00
RandomizedDelaySec=30m
AccuracySec=1m
Persistent=true
Unit=frameflow-restore-rehearsal.service
[Install]
WantedBy=timers.target

324
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,324 @@
name: ${COMPOSE_PROJECT_NAME:-frameflow-prod}
x-server-image: &server-image
image: frameflow-server:${FRAMEFLOW_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile.server
x-server-environment: &server-environment
NODE_ENV: production
API_HOST: 0.0.0.0
API_PORT: 8787
API_PUBLIC_ORIGIN: ${API_PUBLIC_ORIGIN:?Set API_PUBLIC_ORIGIN}
WEB_ORIGIN: ${WEB_ORIGIN:?Set WEB_ORIGIN}
LOG_LEVEL: ${LOG_LEVEL:-info}
METRICS_HOST: 0.0.0.0
WORKER_METRICS_PORT: 9092
WORKER_HEARTBEAT_KEY: ${WORKER_HEARTBEAT_KEY:-frameflow:worker:heartbeat}
WORKER_HEARTBEAT_INTERVAL_MS: ${WORKER_HEARTBEAT_INTERVAL_MS:-5000}
WORKER_HEARTBEAT_TTL_SECONDS: ${WORKER_HEARTBEAT_TTL_SECONDS:-20}
METRICS_TOKEN: ${METRICS_TOKEN:-}
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
REDIS_URL: ${REDIS_URL:?Set REDIS_URL}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?Set JWT_ACCESS_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET}
CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY:?Set CREDENTIAL_ENCRYPTION_KEY}
ACCESS_TOKEN_TTL: ${ACCESS_TOKEN_TTL:-15m}
REFRESH_TOKEN_TTL_DAYS: ${REFRESH_TOKEN_TTL_DAYS:-30}
EMAIL_VERIFICATION_REQUIRED: ${EMAIL_VERIFICATION_REQUIRED:-true}
EMAIL_VERIFICATION_TTL_HOURS: ${EMAIL_VERIFICATION_TTL_HOURS:-24}
PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-30}
RESEND_API_KEY: ${RESEND_API_KEY:?Set RESEND_API_KEY}
EMAIL_FROM: ${EMAIL_FROM:?Set EMAIL_FROM}
S3_ENDPOINT: http://minio:9000
S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:?Set S3_PUBLIC_ENDPOINT}
S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-frameflow}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
S3_FORCE_PATH_STYLE: "true"
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_TEXT_MODEL: ${OPENAI_TEXT_MODEL:-gpt-5.6-sol}
OPENAI_IMAGE_MODEL: ${OPENAI_IMAGE_MODEL:-gpt-image-2}
OPENAI_TTS_MODEL: ${OPENAI_TTS_MODEL:-gpt-4o-mini-tts}
OPENAI_TTS_VOICE: ${OPENAI_TTS_VOICE:-alloy}
AGNES_API_KEY: ${AGNES_API_KEY:-}
AGNES_TEXT_MODEL: ${AGNES_TEXT_MODEL:-agnes-2.5-flash}
AGNES_IMAGE_MODEL: ${AGNES_IMAGE_MODEL:-agnes-image-2.1-flash}
AGNES_VIDEO_MODEL: ${AGNES_VIDEO_MODEL:-agnes-video-v2.0}
AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS: ${AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS:-900}
AGNES_VIDEO_POLL_INTERVAL_MS: ${AGNES_VIDEO_POLL_INTERVAL_MS:-4000}
GENERIC_TEXT_API_KEY: ${GENERIC_TEXT_API_KEY:-}
GENERIC_TEXT_BASE_URL: ${GENERIC_TEXT_BASE_URL:-}
GENERIC_TEXT_MODEL: ${GENERIC_TEXT_MODEL:-}
GENERIC_IMAGE_API_KEY: ${GENERIC_IMAGE_API_KEY:-}
GENERIC_IMAGE_BASE_URL: ${GENERIC_IMAGE_BASE_URL:-}
GENERIC_IMAGE_MODEL: ${GENERIC_IMAGE_MODEL:-}
GENERIC_VIDEO_API_KEY: ${GENERIC_VIDEO_API_KEY:-}
GENERIC_VIDEO_BASE_URL: ${GENERIC_VIDEO_BASE_URL:-}
GENERIC_VIDEO_MODEL: ${GENERIC_VIDEO_MODEL:-}
GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS: ${GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS:-900}
GENERIC_VIDEO_POLL_INTERVAL_MS: ${GENERIC_VIDEO_POLL_INTERVAL_MS:-4000}
REPLICATE_API_TOKEN: ${REPLICATE_API_TOKEN:-}
REPLICATE_IMAGE_VERSION: ${REPLICATE_IMAGE_VERSION:-}
REPLICATE_VIDEO_VERSION: ${REPLICATE_VIDEO_VERSION:-}
REPLICATE_LIPSYNC_VERSION: ${REPLICATE_LIPSYNC_VERSION:-}
REPLICATE_PREDICTION_DEADLINE_SECONDS: ${REPLICATE_PREDICTION_DEADLINE_SECONDS:-900}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID:-}
STRIPE_STUDIO_PRICE_ID: ${STRIPE_STUDIO_PRICE_ID:-}
STRIPE_PORTAL_CONFIGURATION_ID: ${STRIPE_PORTAL_CONFIGURATION_ID:-}
BILLING_SUCCESS_URL: ${BILLING_SUCCESS_URL:-}
BILLING_CANCEL_URL: ${BILLING_CANCEL_URL:-}
PUBLISHING_CONNECTOR_URL: ${PUBLISHING_CONNECTOR_URL:-}
PUBLISHING_CONNECTOR_SECRET: ${PUBLISHING_CONNECTOR_SECRET:-}
PUBLISHING_CONNECTOR_TIMEOUT_MS: ${PUBLISHING_CONNECTOR_TIMEOUT_MS:-30000}
FFMPEG_PATH: ffmpeg
FFPROBE_PATH: ffprobe
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-2}
MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-104857600}
PROVIDER_DOWNLOAD_TIMEOUT_MS: ${PROVIDER_DOWNLOAD_TIMEOUT_MS:-180000}
PROVIDER_DOWNLOAD_MAX_REDIRECTS: ${PROVIDER_DOWNLOAD_MAX_REDIRECTS:-3}
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-frameflow}
POSTGRES_USER: ${POSTGRES_USER:-frameflow}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
redis:
image: redis:7.4-alpine
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD}
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?Set REDIS_PASSWORD}"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
minio:
image: minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
MINIO_API_CORS_ALLOW_ORIGIN: ${WEB_ORIGIN:?Set WEB_ORIGIN}
MINIO_PROMETHEUS_AUTH_TYPE: public
volumes:
- minio_data:/data
ports:
- "${MINIO_CONSOLE_BIND:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
minio-init:
image: minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
depends_on:
minio:
condition: service_healthy
environment:
S3_BUCKET: ${S3_BUCKET:-frameflow}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
entrypoint: ["/bin/sh", "-c"]
command:
- >-
mc alias set local http://minio:9000 "$${S3_ACCESS_KEY}" "$${S3_SECRET_KEY}" &&
mc mb --ignore-existing "local/$${S3_BUCKET}" &&
mc anonymous set none "local/$${S3_BUCKET}"
restart: "no"
config-check:
<<: *server-image
environment: *server-environment
command: ["node", "server/dist/config-check.js"]
read_only: true
tmpfs:
- /tmp:size=16m
restart: "no"
migrate:
<<: *server-image
environment: *server-environment
command: ["node", "server/dist/db/migrate.js"]
depends_on:
config-check:
condition: service_completed_successfully
postgres:
condition: service_healthy
read_only: true
tmpfs:
- /tmp:size=128m
restart: "no"
api:
<<: *server-image
environment: *server-environment
command: ["node", "server/dist/index.js"]
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
read_only: true
tmpfs:
- /tmp:size=512m
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
start_period: 15s
retries: 10
restart: unless-stopped
worker:
<<: *server-image
environment: *server-environment
command: ["node", "server/dist/worker.js"]
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
read_only: true
tmpfs:
- /tmp:size=4g
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:9092/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
start_period: 15s
retries: 10
restart: unless-stopped
web:
image: frameflow-web:${FRAMEFLOW_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile.web
args:
VITE_API_URL: /api/v1
environment:
APP_HOST: ${APP_HOST:?Set APP_HOST}
MEDIA_HOST: ${MEDIA_HOST:?Set MEDIA_HOST}
MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-100m}
ports:
- "${HTTP_BIND:-0.0.0.0}:${HTTP_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
prometheus:
image: prom/prometheus:v3.5.0
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-30d}
volumes:
- ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./deploy/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus_data:/prometheus
ports:
- "${PROMETHEUS_BIND:-127.0.0.1}:${PROMETHEUS_PORT:-9090}:9090"
depends_on:
api:
condition: service_healthy
worker:
condition: service_healthy
minio:
condition: service_healthy
node-exporter:
condition: service_healthy
alertmanager:
condition: service_healthy
restart: unless-stopped
node-exporter:
image: quay.io/prometheus/node-exporter:v1.9.1
command:
- --path.rootfs=/host
- --collector.textfile.directory=/textfile
pid: host
volumes:
- /:/host:ro
- ${FRAMEFLOW_BACKUP_METRICS_DIR:-/var/lib/frameflow/metrics}:/textfile:ro
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:9100/metrics"]
interval: 15s
timeout: 5s
start_period: 5s
retries: 10
restart: unless-stopped
alertmanager:
image: prom/alertmanager:v0.28.1
environment:
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:?Set ALERTMANAGER_WEBHOOK_URL}
entrypoint: ["/bin/sh", "-ec"]
command:
- |-
case "$${ALERTMANAGER_WEBHOOK_URL}" in
https://*) ;;
*) echo "ALERTMANAGER_WEBHOOK_URL must use HTTPS" >&2; exit 1 ;;
esac
escaped_url=$$(printf '%s' "$${ALERTMANAGER_WEBHOOK_URL}" | sed 's/[\\&|]/\\&/g')
sed "s|__ALERTMANAGER_WEBHOOK_URL__|$${escaped_url}|g" /etc/alertmanager/alertmanager.yml.template > /tmp/alertmanager.yml
/bin/amtool check-config /tmp/alertmanager.yml
exec /bin/alertmanager \
--config.file=/tmp/alertmanager.yml \
--storage.path=/alertmanager \
--enable-feature=receiver-name-in-metrics
volumes:
- ./deploy/alertmanager.yml.template:/etc/alertmanager/alertmanager.yml.template:ro
- alertmanager_data:/alertmanager
ports:
- "${ALERTMANAGER_BIND:-127.0.0.1}:${ALERTMANAGER_PORT:-9093}:9093"
read_only: true
tmpfs:
- /tmp:size=16m
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:9093/-/ready"]
interval: 10s
timeout: 5s
start_period: 5s
retries: 10
restart: unless-stopped
volumes:
postgres_data:
redis_data:
minio_data:
prometheus_data:
alertmanager_data:

69
docker-compose.yml Normal file
View File

@@ -0,0 +1,69 @@
name: frameflow
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: frameflow
POSTGRES_USER: frameflow
POSTGRES_PASSWORD: frameflow
ports:
- "54329:5432"
volumes:
- frameflow_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U frameflow -d frameflow"]
interval: 3s
timeout: 3s
retries: 20
restart: unless-stopped
redis:
image: redis:7.4-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- "63799:6379"
volumes:
- frameflow_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 3s
retries: 20
restart: unless-stopped
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: frameflow
MINIO_ROOT_PASSWORD: frameflow-local-secret
ports:
- "9000:9000"
- "9001:9001"
volumes:
- frameflow_minio:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 3s
timeout: 3s
retries: 20
restart: unless-stopped
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >-
/bin/sh -c "
mc alias set local http://minio:9000 frameflow frameflow-local-secret &&
mc mb --ignore-existing local/frameflow &&
mc anonymous set none local/frameflow
"
restart: "no"
volumes:
frameflow_postgres:
frameflow_redis:
frameflow_minio:

350
docs/deployment.md Normal file
View File

@@ -0,0 +1,350 @@
# Production deployment
The production stack contains Nginx, the React web build, the Fastify API, a BullMQ worker, PostgreSQL, Redis, MinIO, Prometheus, and Alertmanager. API and storage services are not published directly. Nginx routes the application host to the SPA/API and the media host to MinIO.
## Prerequisites
- Docker Engine with Compose v2
- Two DNS records pointing to the deployment host, such as `studio.example.com` and `media.example.com`
- An HTTPS reverse proxy or load balancer in front of port `8080`
- OpenAI and Replicate credentials for real generation jobs
- A Stripe account, recurring Price IDs, and webhook signing secret when subscription billing is enabled
- An HTTPS operator webhook that accepts Alertmanager webhook payloads
- An encrypted off-host destination for daily backups
Both public hosts must be forwarded to the same Nginx port with the original `Host` header preserved. TLS is required because production refresh cookies are secure. `S3_PUBLIC_ENDPOINT` must exactly match the public media origin; providers and browsers cannot use the private `http://minio:9000` address.
Production registration requires verified email by default. Configure `RESEND_API_KEY` and a verified `EMAIL_FROM` sender before startup; password-reset, verification, and workspace-invitation links use the first origin in `WEB_ORIGIN` as their public base URL. Workspace invitations remain valid when email delivery fails and the API returns a one-time manual link, but operators should treat a `FAILED` delivery result as an email-provider incident rather than asking the inviter to recreate the workspace.
Subscription billing remains read-only when Stripe is not configured. To enable checkout, set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRO_PRICE_ID`, and `STRIPE_STUDIO_PRICE_ID`, then register `https://studio.example.com/api/v1/billing/webhooks/stripe` for `checkout.session.completed` and `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`. Configure the Stripe Customer Portal before exposing subscription management; `STRIPE_PORTAL_CONFIGURATION_ID` is optional when the account default configuration is used.
Before enabling billing, run the read-only Stripe resource smoke. It never creates a customer, session, charge, subscription, or event. Use `--require-live` for the production account:
```bash
FRAMEFLOW_STRIPE_APP_ORIGIN=https://studio.example.com \
STRIPE_SECRET_KEY='use-the-secret-store-value' \
STRIPE_WEBHOOK_SECRET='use-the-secret-store-value' \
STRIPE_PRO_PRICE_ID=price_replace_with_pro \
STRIPE_STUDIO_PRICE_ID=price_replace_with_studio \
STRIPE_PORTAL_CONFIGURATION_ID=bpc_replace_when_not_using_default \
npm run smoke:stripe -- --require-live --json
```
The smoke retrieves the current account, expands both configured Products from their recurring Prices, validates active licensed recurring billing and live/test mode consistency, finds the explicit or default active Customer Portal configuration, and locates the exact enabled FrameFlow webhook with all four required events. The secret key and webhook signing secret are read only from the environment and are never printed. Stripe does not expose an endpoint's signing secret through its API, so this check can prove that `STRIPE_WEBHOOK_SECRET` is configured but cannot prove it belongs to the discovered endpoint. Complete one signed test subscription event through the FrameFlow webhook and confirm the workspace plan/credit update before accepting billing.
## Publishing connector
Direct delivery to Douyin, Kuaishou, Bilibili, and Xiaohongshu is intentionally isolated behind an operator-owned connector. The connector holds platform application credentials and translates the stable FrameFlow contract into each platform's OAuth, upload, polling, and token-refresh APIs. Without it, download delivery remains available and the publishing UI reports every external channel as not configured.
Configure these values together:
- `API_PUBLIC_ORIGIN`: the public HTTPS FrameFlow API origin used to build OAuth and status callback URLs.
- `CREDENTIAL_ENCRYPTION_KEY`: a production-only secret of at least 32 characters used to encrypt channel access tokens, refresh tokens, and OAuth PKCE verifiers at rest.
- `PUBLISHING_CONNECTOR_URL`: the connector's public HTTPS origin.
- `PUBLISHING_CONNECTOR_SECRET`: a shared secret of at least 24 characters. FrameFlow sends it as `Authorization: Bearer <secret>` and also uses it to verify connector callbacks.
- `PUBLISHING_CONNECTOR_TIMEOUT_MS`: timeout for each connector request, from 1,000 to 120,000 milliseconds.
Every connector request is JSON and includes `platform`, one of `DOUYIN`, `KUAISHOU`, `BILIBILI`, or `XIAOHONGSHU`. The connector must expose:
- `POST /v1/oauth/authorize`: accepts `{ platform, state, callbackUrl }`; returns `{ authorizationUrl, codeVerifier? }`. The platform must return the supplied `state` to `callbackUrl`.
- `POST /v1/oauth/exchange`: accepts `{ platform, code, callbackUrl, codeVerifier? }`; returns `{ externalAccountId, displayName, avatarUrl?, accessToken, refreshToken?, tokenExpiresAt?, scopes?, metadata? }`.
- `POST /v1/oauth/revoke`: accepts `{ platform, externalAccountId, accessToken, refreshToken? }`; returns `{ revoked: boolean }`.
- `POST /v1/publish`: accepts `{ platform, deliveryId, sourceUrl, callbackUrl, account, destination }`; returns `{ status, externalId, publishedUrl?, credentials? }`. `status` is `PUBLISHING` for asynchronous processing or `PUBLISHED` only when the platform has confirmed publication. `sourceUrl` is a short-lived signed media URL and must not be persisted or logged.
For asynchronous work, POST status events to the supplied FrameFlow `callbackUrl` with this body:
```json
{
"eventId": "provider-unique-event-id",
"platform": "DOUYIN",
"deliveryId": "00000000-0000-0000-0000-000000000000",
"externalId": "platform-video-id",
"status": "PUBLISHED",
"occurredAt": "2026-07-31T12:00:00.000Z",
"publishedUrl": "https://platform.example/video/123"
}
```
`status` may be `PUBLISHING`, `PUBLISHED`, or `FAILED`; include `errorMessage` for failures. Sign the exact raw JSON bytes with HMAC-SHA256 using `PUBLISHING_CONNECTOR_SECRET`, and send the lowercase hexadecimal digest as `x-frameflow-signature: sha256=<digest>`. FrameFlow deduplicates events by platform and event ID, rejects stale or mismatched events, and never allows a terminal delivery to regress.
Before enabling a channel in production, bind and revoke a dedicated test account, publish an approved test render, wait for a signed terminal callback, open the returned platform URL, verify title/description/visibility/tags/schedule behavior, and confirm that refreshed credentials remain usable. Configuration-only readiness or a successful queue submission is not acceptance evidence.
## Continuous integration
`.github/workflows/ci.yml` provides two required pre-deployment checks. The verification job starts isolated PostgreSQL, Redis, and MinIO services, applies every migration, runs all web and server tests, creates a real backup of those services, restores it through the isolated rehearsal path, builds both applications, and audits production dependencies. The container job validates the production Compose model, builds the server and web images, confirms a synthetic safe production configuration is accepted, and confirms `.env.production.example` is rejected because it still contains placeholders.
The synthetic values exist only inside the CI runner and are not deployment secrets. CI proves the repository is internally buildable and the safety gate works; it does not prove external provider credentials, public TLS/media routing, email delivery, billing webhooks, alert delivery, or cloud generation jobs. Complete the live checks below for every release candidate deployed to a real environment.
## Production smoke test
Use a dedicated, verified account with the `OWNER` or `ADMIN` role. The smoke runner accepts its password only through the environment, requires HTTPS outside localhost, and does not disable certificate validation. Its default mode never starts OpenAI or Replicate jobs. It performs these checks through the public application origin:
- API liveness plus PostgreSQL, Redis, object-storage, and worker readiness
- authenticated service readiness including FFmpeg, AI configuration, and any persisted provider-verification state without starting a new provider request
- billing/usage read-model access
- project, episode, immutable script, asset, and shot-version persistence
- multipart object upload plus signed-download SHA-256 integrity
- a real Redis/BullMQ worker health job
- a 640x360 FFmpeg render, immutable source manifest, SRT/VTT sidecars, and signed video download
- render review approval, audit-log persistence, download delivery, redirect handling, and delivered-video integrity
- durable render/delivery notifications, user scoping, and persisted read state
```bash
FRAMEFLOW_SMOKE_BASE_URL=https://studio.example.com \
FRAMEFLOW_SMOKE_EMAIL=production-smoke@example.com \
FRAMEFLOW_SMOKE_PASSWORD='use-the-secret-store-value' \
FRAMEFLOW_SMOKE_WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \
npm run smoke:production -- --json
```
The workspace ID is optional when the account belongs to exactly one workspace. The default per-job timeout is 180 seconds and can be changed with `--timeout-seconds`. The test creates a uniquely marked project and archives it in a `finally` cleanup path on success or failure; use `--keep-project` only for diagnosis. Archiving preserves its database history and private media objects, so apply an operator-reviewed retention policy to old `[SMOKE]` projects rather than granting the test runner destructive storage permissions.
For local-only verification, explicitly allow an HTTP loopback origin:
```bash
FRAMEFLOW_SMOKE_BASE_URL=http://127.0.0.1:8787 \
FRAMEFLOW_SMOKE_ALLOW_HTTP=true \
FRAMEFLOW_SMOKE_EMAIL=studio@frameflow.local \
FRAMEFLOW_SMOKE_PASSWORD='FrameFlow2026!' \
npm run smoke:production -- --timeout-seconds 120
```
A passing default report proves the local production path, storage, queue, render, review, and delivery adapters worked for that deployment. It never contacts an AI provider. AI readiness is configuration-only unless an owner or administrator previously completed the provider connection check; persisted connection status still does not replace the billable generation acceptance run.
To validate the live provider path, add `--include-ai` and the exact billable-charge acknowledgement. Both are required; setting the environment acknowledgement alone never enables provider calls. Run this only in a workspace where test media and provider charges are acceptable:
```bash
FRAMEFLOW_SMOKE_BASE_URL=https://studio.example.com \
FRAMEFLOW_SMOKE_EMAIL=production-smoke@example.com \
FRAMEFLOW_SMOKE_PASSWORD='use-the-secret-store-value' \
FRAMEFLOW_SMOKE_WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \
FRAMEFLOW_SMOKE_BILLABLE_AI_ACK=I_ACCEPT_BILLABLE_AI_CHARGES \
FRAMEFLOW_SMOKE_AI_VIDEO_SECONDS=5 \
npm run smoke:production -- --include-ai --json
```
The live preflight runs before project creation and requires all seven workflows (`script`, `storyboard`, `image-openai`, `image-replicate`, `tts`, `video`, and `lipsync`) to be `CONFIGURED`, plus at least 700 remaining workspace credits. It then creates one OpenAI character image, one Replicate consistency variant, an OpenAI script and storyboard, OpenAI speech, Replicate video and lipsync media, and an FFmpeg render. Every provider job uses `maxAttempts: 1`; the report verifies stored media, provider/model attribution, the final render, and one usage-ledger entry per provider job. The uniquely marked smoke project is archived in the same cleanup path used by default mode unless `--keep-project` is supplied.
`FRAMEFLOW_SMOKE_AI_VIDEO_SECONDS` accepts 1 to 30 seconds and defaults to 5. Some configured Replicate model versions require additional input fields. Supply only fields documented by those exact model versions as JSON objects through `FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON` and `FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON`; the runner rejects malformed JSON and arrays before login. Leave them unset when the selected versions need no extra parameters. Do not put provider tokens or other secrets in these JSON values or command-line arguments.
Example Caddy routing in front of the stack:
```caddyfile
studio.example.com {
reverse_proxy 127.0.0.1:8080
}
media.example.com {
reverse_proxy 127.0.0.1:8080
}
```
The web client keeps one authenticated server-sent event stream open at `/api/v1/workspaces/:workspaceId/events`. The supplied API response disables proxy buffering and sends a heartbeat every 20 seconds. Preserve the `X-Accel-Buffering: no` response header, keep the upstream read timeout above 60 seconds, and do not enable response caching or compression that buffers `text/event-stream`. Notification history and read state are persisted in PostgreSQL, so reconnecting clients recover missed events through the regular notification endpoint; the existing task poller remains a degraded-mode fallback when streaming is unavailable.
## First deployment
1. Create the production environment file and replace every placeholder secret.
```bash
cp .env.production.example .env.production
openssl rand -hex 32
```
2. Create the host directory shared by the backup script and Node Exporter. If `FRAMEFLOW_BACKUP_METRICS_DIR` is changed in `.env.production`, use the same path here and in the backup scheduler environment.
```bash
sudo install -d -m 0755 -o "$(id -un)" -g "$(id -gn)" /var/lib/frameflow/metrics
```
3. Validate interpolation, build the lightweight configuration gate, and run it before starting any stateful application service.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml config --quiet
docker compose --env-file .env.production -f docker-compose.prod.yml build config-check
docker compose --env-file .env.production -f docker-compose.prod.yml run --rm --no-deps config-check
```
The gate rejects development defaults, placeholder credentials, identical JWT signing secrets, localhost or non-HTTPS public endpoints, unauthenticated database/Redis URLs, Replicate model versions without a token, and partially configured Stripe billing. It prints only a sanitized capability summary and never logs credential values. The migration service also depends on this check, so an unsafe configuration cannot migrate or start the API through the supplied Compose stack.
4. Build and start the stack. Database migrations and bucket initialization run as one-shot dependencies before the API and worker start.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build
docker compose --env-file .env.production -f docker-compose.prod.yml ps
```
5. Verify public and internal health.
```bash
curl -fsS https://studio.example.com/health/live
curl -fsS https://studio.example.com/health/ready
docker compose --env-file .env.production -f docker-compose.prod.yml exec api node -e "fetch('http://127.0.0.1:8787/health/ready').then(r=>r.text()).then(console.log)"
docker compose --env-file .env.production -f docker-compose.prod.yml exec worker node -e "fetch('http://127.0.0.1:9092/health/ready').then(r=>r.text()).then(console.log)"
```
API readiness includes PostgreSQL, Redis, object storage, and the Redis-backed worker heartbeat. A running API returns `503 not_ready` when the generation worker is absent or stale, so the web service is not promoted while production jobs cannot run.
6. Create the first workspace owner through the registration API, then use that account on the login screen.
```bash
curl -fsS https://studio.example.com/api/v1/auth/register \
-H 'content-type: application/json' \
--data '{"email":"owner@example.com","password":"replace-with-a-long-password","displayName":"Studio Owner","workspaceName":"My Studio"}'
```
Public registration should be disabled or protected at the edge after account provisioning when operating an invite-only deployment.
## Media and model checks
MinIO CORS is restricted to `WEB_ORIGIN` for browser requests. CORS does not make objects public; every object remains private and requires a short-lived signature.
Before accepting production work, verify one job of every configured provider type:
- script generation and storyboard generation
- character image generation using reference asset versions
- speech generation
- image-to-video generation
- lipsync generation
- episode render, immutable source-manifest capture, SRT/VTT subtitle download, and approved video delivery
Workspace owners and administrators can run a non-generating connection check from the AI Services page. OpenAI verification retrieves every distinct configured text, image, and speech model through the Models API. Replicate verification reads the authenticated account endpoint, so it proves the token is accepted but does not prove that a configured model version can complete a prediction. Results are stored per workspace, audited, and automatically ignored after a credential or model configuration change. Provider response bodies and credentials are never stored. Complete the live billable workflow below before treating generation as accepted.
Replicate must be able to fetch the public media origin over HTTPS. Provider output downloads are accepted only from public HTTPS addresses; every redirect is revalidated, streamed data is capped by `MAX_UPLOAD_BYTES`, and `PROVIDER_DOWNLOAD_TIMEOUT_MS` plus `PROVIDER_DOWNLOAD_MAX_REDIRECTS` bound stalled or redirecting responses. Do not mark provider validation complete when only queue creation succeeds; inspect the resulting media and usage ledger entry.
`REPLICATE_PREDICTION_DEADLINE_SECONDS` controls both the local polling deadline and Replicate's `Cancel-After` deadline. It accepts 5 to 86,400 seconds and defaults to 900. Set it high enough for the selected model versions, but keep it bounded so a worker timeout does not leave a remote prediction running and accruing charges.
## Monitoring
Prometheus listens on `127.0.0.1:9090` by default and scrapes API, worker, host, and MinIO metrics over the private Compose network. MinIO's metrics endpoint is unauthenticated only inside that network; Nginx returns `404` for both v2 and v3 metrics paths on the public media host. Alertmanager listens on `127.0.0.1:9093` and sends grouped firing and resolved notifications to `ALERTMANAGER_WEBHOOK_URL`. The URL is rendered into a temporary in-container config and must use HTTPS. Keep `METRICS_TOKEN` empty with the supplied scrape configuration. If metrics are exposed outside the Compose network, configure a token and update Prometheus authorization at the same time.
Useful checks:
```bash
curl -fsS http://127.0.0.1:9090/-/ready
curl -fsS http://127.0.0.1:9093/-/ready
curl -fsS 'http://127.0.0.1:9090/api/v1/targets'
curl -fsS 'http://127.0.0.1:9090/api/v1/rules'
curl -fsS 'http://127.0.0.1:9090/api/v1/alerts'
curl -fsS 'http://127.0.0.1:9090/api/v1/alertmanagers'
docker compose --env-file .env.production -f docker-compose.prod.yml logs --since=30m api worker node-exporter prometheus alertmanager
```
The supplied `deploy/alerts.yml` rules cover API/worker/exporter disappearance, stale worker heartbeat, dependency readiness, host and PostgreSQL-volume disk pressure, MinIO usable capacity, backup age, restore-rehearsal age, API error rate and latency, generation failure rate, and queue delay. Prometheus evaluates these rules and routes alerts to the bundled Alertmanager. The exact `FrameFlowDeliveryTest` alert uses a dedicated one-second group wait and five-second group interval so production acceptance does not change the timing of operational alerts.
Run the controlled delivery smoke before launch. The exact acknowledgement is required because this command sends real firing and resolved notifications to the configured operator webhook:
```bash
FRAMEFLOW_ALERTMANAGER_URL=http://127.0.0.1:9093 \
FRAMEFLOW_ALERT_TEST_ACK=I_ACCEPT_TEST_ALERT_NOTIFICATIONS \
npm run smoke:alertmanager -- --json
```
The production Alertmanager enables its supported `receiver-name-in-metrics` feature so the command can scope notification counters to `frameflow-operator-webhook`. The command records those counters, posts a uniquely labeled firing alert, waits for the alert to become active and for a successful webhook attempt, resolves the same alert, then waits for both the inactive state and a second successful webhook attempt. It fails when Alertmanager reports a failed notification and always attempts to resolve a firing test alert after an intermediate error. A passing result proves that the webhook returned success for both payloads. Use the returned `testId` to confirm the receiver performed its own downstream processing; Alertmanager cannot prove work performed after the receiver acknowledged the HTTP request.
## Backups
The backup script creates a PostgreSQL custom-format dump plus Redis and MinIO volume archives, then writes checksums. Before publishing success metrics it runs the read-only backup verifier, which checks the required file set, manifest shape, exact checksum inventory, PostgreSQL custom-format signature, readable gzip archives, and unsafe absolute or parent-directory archive paths. Only after every check succeeds does it atomically publish backup timestamp, duration, and size metrics for Node Exporter. Run it from the repository root or a daily scheduler with an explicit off-host destination. A failed run leaves the previous success timestamp unchanged so the stale-backup alert can fire.
```bash
FRAMEFLOW_ENV_FILE=/srv/frameflow/.env.production \
FRAMEFLOW_BACKUP_METRICS_DIR=/var/lib/frameflow/metrics \
/srv/frameflow/scripts/backup.sh /mnt/encrypted-backups/frameflow
```
After the first successful backup, verify the metric through Prometheus:
```bash
curl -fsS --get http://127.0.0.1:9090/api/v1/query \
--data-urlencode 'query=frameflow_backup_last_success_timestamp_seconds'
```
Copy completed backup directories off the application host. A local Docker volume is not a disaster-recovery backup. Test checksum validation and restore into an isolated Compose project on a regular schedule.
### Scheduled backup and restore rehearsal
The supplied systemd units assume a system Docker daemon, an application checkout at `/srv/frameflow`, a `frameflow` service account with supplementary membership in the `docker` group, an encrypted mount at `/mnt/encrypted-backups`, and the standard metrics directory. Change every matching path in both service units when the host layout differs; changing only the environment file is insufficient because the systemd filesystem allowlist and mount assertion are intentionally explicit.
Create the service account according to the host's account-management policy, mount the encrypted off-host filesystem through `/etc/fstab` or a dedicated mount unit, then install the configuration:
```bash
sudo install -d -m 0750 -o root -g frameflow /etc/frameflow
sudo install -d -m 0750 -o frameflow -g frameflow /var/lib/frameflow/metrics
sudo install -d -m 0700 -o frameflow -g frameflow /mnt/encrypted-backups/frameflow
sudo chown root:frameflow /srv/frameflow/.env.production
sudo chmod 0640 /srv/frameflow/.env.production
sudo install -m 0640 -o root -g frameflow \
deploy/systemd/frameflow-backup.env.example /etc/frameflow/backup.env
sudo install -m 0644 deploy/systemd/*.service deploy/systemd/*.timer /etc/systemd/system/
sudoedit /etc/frameflow/backup.env
sudo systemd-analyze verify \
/etc/systemd/system/frameflow-backup.service \
/etc/systemd/system/frameflow-backup.timer \
/etc/systemd/system/frameflow-restore-rehearsal.service \
/etc/systemd/system/frameflow-restore-rehearsal.timer
sudo systemctl daemon-reload
sudo systemctl enable --now frameflow-backup.timer frameflow-restore-rehearsal.timer
```
`scheduled-maintenance.sh` resolves both configured paths, rejects relative paths, root, whitespace, symbolic links, destinations outside the mount, and inactive mount points. This prevents an unavailable encrypted mount from silently redirecting backups onto the application host. The monthly restore unit requires the daily backup service and waits for a fresh successful backup before selecting the newest timestamped directory. It verifies that directory once before the restore script verifies it again immediately before any Docker call.
Run both units manually during first deployment and inspect their logs and timers:
```bash
sudo systemctl start frameflow-backup.service
sudo systemctl start frameflow-restore-rehearsal.service
sudo systemctl status frameflow-backup.service frameflow-restore-rehearsal.service
sudo systemctl list-timers frameflow-backup.timer frameflow-restore-rehearsal.timer
journalctl -u frameflow-backup.service -u frameflow-restore-rehearsal.service --since today
```
The units intentionally do not delete old backups. Apply an operator-reviewed retention or immutable-storage lifecycle policy at the off-host destination, and keep at least one verified recovery point outside that policy's deletion window.
Re-run verification after every off-host transfer and before any restore:
```bash
./scripts/verify-backup.sh /mnt/encrypted-backups/frameflow/frameflow-20260731T040000Z
```
Verification is read-only and does not connect to or modify the running stack. Do not proceed when it reports a checksum, manifest, dump-format, or archive-path failure.
At least monthly, restore a recently transferred backup into the isolated rehearsal environment:
```bash
FRAMEFLOW_RESTORE_METRICS_DIR=/var/lib/frameflow/metrics \
npm run restore:rehearse -- \
/mnt/encrypted-backups/frameflow/frameflow-20260731T040000Z
```
The rehearsal verifies the backup before making any Docker call. It creates a randomly named internal network, three new empty volumes, and temporary PostgreSQL, Redis, and MinIO containers without publishing host ports. Every resource carries both a restore-rehearsal marker and a run-specific label. Cleanup rechecks both labels and refuses to remove a resource if either label differs, so the script never removes the live Compose project or an unrelated similarly named resource.
PostgreSQL validation restores the custom-format dump with `--exit-on-error`, checks migration history and core tables, and queries representative business tables. Redis validation loads the restored persistence, checks `PING`, `DBSIZE`, loading state, and AOF health. MinIO validation starts the production-pinned server image and uses the pinned `mc` client to check administrative readiness and list restored buckets and objects. On success, the script cleans the temporary resources and atomically publishes timestamp, duration, PostgreSQL table-count, Redis key-count, and MinIO object-count metrics for Node Exporter.
Failed rehearsals are cleaned automatically. For incident diagnosis only, add `--keep-on-failure`; the command prints the exact retained resource names and label values. Inspect those labels before manually removing anything. The `FrameFlowRestoreRehearsalStale` alert fires when no successful rehearsal metric exists or the last success is older than 35 days.
Verify both disaster-recovery freshness metrics through Prometheus:
```bash
curl -fsS --get http://127.0.0.1:9090/api/v1/query \
--data-urlencode 'query={__name__=~"frameflow_(backup|restore_rehearsal)_last_success_timestamp_seconds"}'
```
For recovery, stop application writers first:
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml stop web api worker
```
Restore `postgres.dump` with `pg_restore` and unpack the Redis/MinIO archives only into newly created, empty recovery volumes. Restoring volume archives over live data can leave stale objects and is not supported. Start dependencies, run the `migrate` service, then start API, worker, and web and complete the media/model checks above.
## Upgrades
Use an immutable `FRAMEFLOW_VERSION`, build the new images, take a backup, and then recreate services. The migration job completes before new API and worker containers become healthy.
```bash
./scripts/backup.sh /mnt/encrypted-backups/frameflow
docker compose --env-file .env.production -f docker-compose.prod.yml build
docker compose --env-file .env.production -f docker-compose.prod.yml up -d
docker compose --env-file .env.production -f docker-compose.prod.yml ps
```
Rollback application images only after confirming that the new migration remains backward compatible. Restore the database from backup when a migration is not reversible.

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17191c" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23f15a43'/%3E%3Cpath d='M18 15h28v34H18zM18 25h28M18 39h28M26 15v34M38 15v34' fill='none' stroke='white' stroke-width='3'/%3E%3C/svg%3E" />
<title>FrameFlow AI 漫剧工场</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6502
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "frameflow-ai-comic-studio",
"private": true,
"version": "0.1.0",
"type": "module",
"workspaces": [
"server"
],
"scripts": {
"dev": "concurrently -n WEB,API,WORKER -c cyan,green,yellow \"npm:dev:web\" \"npm:dev:api\" \"npm:dev:worker\"",
"dev:web": "vite",
"dev:api": "npm run dev --workspace @frameflow/server",
"dev:worker": "npm run worker --workspace @frameflow/server",
"infra:up": "docker compose up -d postgres redis minio minio-init",
"infra:down": "docker compose down",
"backup:verify": "./scripts/verify-backup.sh",
"restore:rehearse": "./scripts/rehearse-restore.sh",
"smoke:production": "node scripts/smoke-test.mjs",
"smoke:alertmanager": "node scripts/alertmanager-smoke.mjs",
"smoke:stripe": "node scripts/stripe-smoke.mjs",
"db:generate": "npm run db:generate --workspace @frameflow/server",
"db:migrate": "npm run db:migrate --workspace @frameflow/server",
"db:seed": "npm run db:seed --workspace @frameflow/server",
"test": "npm run test:web && npm run test:server",
"test:web": "vitest run --config vitest.config.js",
"test:web:watch": "vitest --config vitest.config.js",
"test:server": "npm run test --workspace @frameflow/server",
"build": "vite build",
"build:server": "npm run build --workspace @frameflow/server",
"build:all": "npm run build && npm run build:server",
"preview": "vite preview"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"typescript": "latest",
"react": "latest",
"react-dom": "latest",
"lucide-react": "latest",
"concurrently": "^9.2.1"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

310
scripts/alertmanager-smoke.mjs Executable file
View File

@@ -0,0 +1,310 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const acknowledgement = 'I_ACCEPT_TEST_ALERT_NOTIFICATIONS'
const defaultReceiver = 'frameflow-operator-webhook'
export class AlertmanagerSmokeError extends Error {
constructor(message, options = {}) {
super(message, options)
this.name = 'AlertmanagerSmokeError'
}
}
function required(value, name) {
if (typeof value !== 'string' || value.trim() === '') throw new AlertmanagerSmokeError(`${name} is required`)
return value.trim()
}
function integerValue(value, name, minimum, maximum) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new AlertmanagerSmokeError(`${name} must be an integer from ${minimum} to ${maximum}`)
}
return parsed
}
export function resolveAlertmanagerUrl(value) {
let url
try {
url = new URL(required(value, 'FRAMEFLOW_ALERTMANAGER_URL'))
} catch (error) {
if (error instanceof AlertmanagerSmokeError) throw error
throw new AlertmanagerSmokeError('FRAMEFLOW_ALERTMANAGER_URL must be a valid URL')
}
if (url.username || url.password || url.search || url.hash) {
throw new AlertmanagerSmokeError('Alertmanager URL cannot contain credentials, a query, or a fragment')
}
if (!['http:', 'https:'].includes(url.protocol)) {
throw new AlertmanagerSmokeError('Alertmanager URL must use HTTP or HTTPS')
}
const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
if (url.protocol === 'http:' && !localHostnames.has(url.hostname)) {
throw new AlertmanagerSmokeError('HTTP is allowed only for a loopback Alertmanager URL')
}
if (url.pathname !== '/' && url.pathname !== '') {
throw new AlertmanagerSmokeError('Alertmanager URL must not contain a path')
}
return url.origin
}
export function usage() {
return `Usage: npm run smoke:alertmanager -- [options]
Sends a real FrameFlowDeliveryTest alert and resolution through Alertmanager,
then verifies successful webhook notification counters for both events.
Required environment:
FRAMEFLOW_ALERT_TEST_ACK=${acknowledgement}
Optional environment:
FRAMEFLOW_ALERTMANAGER_URL=http://127.0.0.1:9093
FRAMEFLOW_ALERT_TEST_RECEIVER=${defaultReceiver}
FRAMEFLOW_ALERT_TEST_TIMEOUT_SECONDS=60
Options:
--url <origin> Alertmanager origin; HTTP is loopback-only
--receiver <name> Receiver label used by notification metrics
--timeout-seconds <15-300>
--json
--help
This command causes real firing and resolved webhook notifications. A passing
report proves that the configured webhook returned success to Alertmanager; it
does not prove downstream processing after that acknowledgement.
`
}
export function parseAlertmanagerSmokeOptions(argv = process.argv.slice(2), environment = process.env) {
const values = {}
const flags = new Set()
const valueOptions = new Set(['--url', '--receiver', '--timeout-seconds'])
const flagOptions = new Set(['--json', '--help'])
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (valueOptions.has(argument)) {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new AlertmanagerSmokeError(`${argument} requires a value`)
values[argument] = value
index += 1
} else if (flagOptions.has(argument)) {
flags.add(argument)
} else {
throw new AlertmanagerSmokeError(`Unknown option: ${argument}`)
}
}
if (flags.has('--help')) return { help: true }
if (environment.FRAMEFLOW_ALERT_TEST_ACK !== acknowledgement) {
throw new AlertmanagerSmokeError(`Alert smoke requires FRAMEFLOW_ALERT_TEST_ACK=${acknowledgement}`)
}
const receiver = required(values['--receiver'] ?? environment.FRAMEFLOW_ALERT_TEST_RECEIVER ?? defaultReceiver, 'receiver')
if (!/^[a-zA-Z0-9_.-]{1,100}$/.test(receiver)) {
throw new AlertmanagerSmokeError('receiver must contain only letters, digits, dot, underscore, or hyphen')
}
const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_ALERT_TEST_TIMEOUT_SECONDS ?? '60', 'timeout-seconds', 15, 300)
return {
alertmanagerUrl: resolveAlertmanagerUrl(values['--url'] ?? environment.FRAMEFLOW_ALERTMANAGER_URL ?? 'http://127.0.0.1:9093'),
receiver,
timeoutMs: timeoutSeconds * 1000,
requestTimeoutMs: Math.min(10_000, timeoutSeconds * 1000),
pollIntervalMs: 500,
json: flags.has('--json'),
}
}
function unescapePrometheusLabel(value) {
return value.replace(/\\([\\"n])/g, (_match, escaped) => escaped === 'n' ? '\n' : escaped)
}
function parsePrometheusLabels(value) {
const labels = {}
const matcher = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:\\.|[^"\\])*)"/g
for (const match of value.matchAll(matcher)) labels[match[1]] = unescapePrometheusLabel(match[2])
return labels
}
export function notificationCounters(metricsText, receiver) {
const counters = { total: 0, failed: 0 }
for (const line of String(metricsText).split('\n')) {
const match = line.match(/^(alertmanager_notifications(?:_failed)?_total)\{([^}]*)\}\s+([^\s]+)$/)
if (!match) continue
const labels = parsePrometheusLabels(match[2])
if (labels.integration !== 'webhook' || labels.receiver_name !== receiver) continue
const value = Number(match[3])
if (!Number.isFinite(value)) continue
if (match[1] === 'alertmanager_notifications_failed_total') counters.failed += value
else counters.total += value
}
return counters
}
function safeResponseText(value) {
return String(value ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 500)
}
async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs)
try {
return await fetchImpl(url, { ...options, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
async function expectOk(fetchImpl, url, options, timeoutMs) {
const response = await fetchWithTimeout(fetchImpl, url, options, timeoutMs)
if (!response.ok) {
throw new AlertmanagerSmokeError(`${options.method ?? 'GET'} ${new URL(url).pathname} failed (${response.status}): ${safeResponseText(await response.text())}`)
}
return response
}
async function postAlert(fetchImpl, options, alert) {
await expectOk(fetchImpl, `${options.alertmanagerUrl}/api/v2/alerts`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify([alert]),
}, options.requestTimeoutMs)
}
async function listTestAlerts(fetchImpl, options, testId) {
const matcher = encodeURIComponent(`test_id="${testId}"`)
const response = await expectOk(fetchImpl, `${options.alertmanagerUrl}/api/v2/alerts?active=true&silenced=true&inhibited=true&unprocessed=true&filter=${matcher}`, {}, options.requestTimeoutMs)
const body = await response.json()
if (!Array.isArray(body)) throw new AlertmanagerSmokeError('Alertmanager returned an invalid alert list')
return body.filter((alert) => alert?.labels?.test_id === testId)
}
async function readNotificationCounters(fetchImpl, options) {
const response = await expectOk(fetchImpl, `${options.alertmanagerUrl}/metrics`, {}, options.requestTimeoutMs)
return notificationCounters(await response.text(), options.receiver)
}
async function waitUntil(check, description, options, dependencies) {
const deadline = dependencies.now() + options.timeoutMs
let lastError = null
do {
try {
const result = await check()
if (result?.fatal) throw result.error
if (result?.done) return result.value
} catch (error) {
lastError = error
}
if (dependencies.now() >= deadline) break
await dependencies.sleep(Math.min(options.pollIntervalMs, Math.max(1, deadline - dependencies.now())))
} while (dependencies.now() <= deadline)
const suffix = lastError instanceof Error ? `: ${lastError.message}` : ''
throw new AlertmanagerSmokeError(`Timed out waiting for ${description}${suffix}`)
}
async function waitForAlertState(fetchImpl, options, testId, expectedActive, dependencies) {
return waitUntil(async () => {
const alerts = await listTestAlerts(fetchImpl, options, testId)
const active = alerts.some((alert) => Date.parse(alert.endsAt) > dependencies.now())
return active === expectedActive ? { done: true, value: alerts } : { done: false }
}, expectedActive ? 'the test alert to become active' : 'the test alert to resolve', options, dependencies)
}
async function waitForSuccessfulNotification(fetchImpl, options, baseline, phase, dependencies) {
return waitUntil(async () => {
const counters = await readNotificationCounters(fetchImpl, options)
if (counters.failed > baseline.failed) {
return {
fatal: true,
error: new AlertmanagerSmokeError(`${phase} webhook notification failed according to Alertmanager metrics`),
}
}
return counters.total > baseline.total ? { done: true, value: counters } : { done: false }
}, `a successful ${phase} webhook notification`, options, dependencies)
}
export async function runAlertmanagerSmoke(options, dependencies = {}) {
const fetchImpl = dependencies.fetchImpl ?? fetch
const now = dependencies.now ?? Date.now
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)))
const createId = dependencies.createId ?? randomUUID
const runtime = { now, sleep }
const testId = createId()
const startsAt = new Date(now()).toISOString()
const firingAlert = {
labels: {
alertname: 'FrameFlowDeliveryTest',
severity: 'info',
service: 'frameflow',
test_id: testId,
},
annotations: {
summary: 'FrameFlow controlled Alertmanager delivery test',
description: `Controlled firing notification for deployment acceptance test ${testId}.`,
},
startsAt,
endsAt: new Date(now() + 15 * 60_000).toISOString(),
generatorURL: `${options.alertmanagerUrl}/#/alerts`,
}
const baseline = await readNotificationCounters(fetchImpl, options)
let firingPosted = false
let resolutionPosted = false
try {
await postAlert(fetchImpl, options, firingAlert)
firingPosted = true
await waitForAlertState(fetchImpl, options, testId, true, runtime)
const afterFiring = await waitForSuccessfulNotification(fetchImpl, options, baseline, 'firing', runtime)
const resolvedAt = new Date(now()).toISOString()
await postAlert(fetchImpl, options, { ...firingAlert, endsAt: resolvedAt })
resolutionPosted = true
await waitForAlertState(fetchImpl, options, testId, false, runtime)
const afterResolved = await waitForSuccessfulNotification(fetchImpl, options, afterFiring, 'resolved', runtime)
return {
status: 'passed',
testId,
receiver: options.receiver,
alertmanagerUrl: options.alertmanagerUrl,
startsAt,
resolvedAt,
firingWebhookAccepted: true,
resolvedWebhookAccepted: true,
notificationDelta: afterResolved.total - baseline.total,
failedNotificationDelta: afterResolved.failed - baseline.failed,
downstreamProcessingProven: false,
}
} catch (error) {
if (firingPosted && !resolutionPosted) {
try {
await postAlert(fetchImpl, options, { ...firingAlert, endsAt: new Date(now()).toISOString() })
} catch (cleanupError) {
throw new AlertmanagerSmokeError(`${error instanceof Error ? error.message : String(error)}; cleanup resolution also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { cause: error })
}
}
throw error
}
}
async function main() {
const options = parseAlertmanagerSmokeOptions()
if (options.help) {
process.stdout.write(usage())
return
}
const report = await runAlertmanagerSmoke(options)
if (options.json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
return
}
process.stdout.write(`Alertmanager webhook smoke passed.\nTest ID: ${report.testId}\nReceiver: ${report.receiver}\nFiring and resolved webhooks returned success; confirm downstream processing with the test ID.\n`)
}
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) {
main().catch((error) => {
process.stderr.write(`Alertmanager smoke failed: ${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}

103
scripts/backup.sh Executable file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
set -Eeuo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_dir="$(cd "${script_dir}/.." && pwd)"
env_file="${FRAMEFLOW_ENV_FILE:-${repo_dir}/.env.production}"
backup_parent="${1:-${repo_dir}/backups}"
metrics_dir="${FRAMEFLOW_BACKUP_METRICS_DIR:-/var/lib/frameflow/metrics}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_dir="${backup_parent%/}/frameflow-${timestamp}"
started_at_seconds="$(date +%s)"
metrics_tmp=""
cleanup() {
if [[ -n "${metrics_tmp}" && -f "${metrics_tmp}" ]]; then
rm -f "${metrics_tmp}"
fi
}
trap cleanup EXIT
if [[ ! -f "${env_file}" ]]; then
echo "Production environment file not found: ${env_file}" >&2
exit 1
fi
compose=(docker compose --env-file "${env_file}" -f "${repo_dir}/docker-compose.prod.yml")
mkdir -p "${backup_parent}"
if ! mkdir "${backup_dir}"; then
echo "Refusing to overwrite an existing backup directory: ${backup_dir}" >&2
exit 1
fi
postgres_container="$("${compose[@]}" ps -q postgres)"
redis_container="$("${compose[@]}" ps -q redis)"
minio_container="$("${compose[@]}" ps -q minio)"
for container in "${postgres_container}" "${redis_container}" "${minio_container}"; do
if [[ -z "${container}" ]]; then
echo "PostgreSQL, Redis, and MinIO must be running before backup." >&2
exit 1
fi
done
echo "Creating PostgreSQL logical backup..."
"${compose[@]}" exec -T postgres sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "${backup_dir}/postgres.dump"
echo "Flushing and archiving Redis persistence..."
"${compose[@]}" exec -T redis sh -c '
if [ -n "${REDIS_PASSWORD:-}" ]; then
redis-cli --no-auth-warning -a "$REDIS_PASSWORD" SAVE >/dev/null
else
redis-cli SAVE >/dev/null
fi
'
docker run --rm --volumes-from "${redis_container}" -v "${backup_dir}:/backup" alpine:3.20 \
tar -czf /backup/redis-data.tar.gz -C /data .
echo "Archiving MinIO object data..."
docker run --rm --volumes-from "${minio_container}" -v "${backup_dir}:/backup" alpine:3.20 \
tar -czf /backup/minio-data.tar.gz -C /data .
{
echo "created_at=${timestamp}"
echo "compose_project=$(docker inspect --format '{{ index .Config.Labels "com.docker.compose.project" }}' "${postgres_container}")"
echo "postgres_container=${postgres_container}"
echo "redis_container=${redis_container}"
echo "minio_container=${minio_container}"
} > "${backup_dir}/manifest.txt"
if command -v sha256sum >/dev/null 2>&1; then
(cd "${backup_dir}" && sha256sum postgres.dump redis-data.tar.gz minio-data.tar.gz > SHA256SUMS)
else
(cd "${backup_dir}" && shasum -a 256 postgres.dump redis-data.tar.gz minio-data.tar.gz > SHA256SUMS)
fi
"${repo_dir}/scripts/verify-backup.sh" "${backup_dir}"
completed_at_seconds="$(date +%s)"
backup_size_bytes=0
for backup_file in postgres.dump redis-data.tar.gz minio-data.tar.gz manifest.txt SHA256SUMS; do
file_size="$(wc -c < "${backup_dir}/${backup_file}" | tr -d '[:space:]')"
backup_size_bytes=$((backup_size_bytes + file_size))
done
mkdir -p "${metrics_dir}"
metrics_tmp="$(mktemp "${metrics_dir%/}/frameflow_backup.prom.tmp.XXXXXX")"
cat > "${metrics_tmp}" <<EOF
# HELP frameflow_backup_last_success_timestamp_seconds Unix timestamp of the last complete FrameFlow backup.
# TYPE frameflow_backup_last_success_timestamp_seconds gauge
frameflow_backup_last_success_timestamp_seconds ${completed_at_seconds}
# HELP frameflow_backup_last_size_bytes Total size of files in the last complete FrameFlow backup.
# TYPE frameflow_backup_last_size_bytes gauge
frameflow_backup_last_size_bytes ${backup_size_bytes}
# HELP frameflow_backup_last_duration_seconds Duration of the last complete FrameFlow backup.
# TYPE frameflow_backup_last_duration_seconds gauge
frameflow_backup_last_duration_seconds $((completed_at_seconds - started_at_seconds))
EOF
chmod 0644 "${metrics_tmp}"
mv -f "${metrics_tmp}" "${metrics_dir%/}/frameflow_backup.prom"
metrics_tmp=""
echo "Backup complete: ${backup_dir}"
echo "Backup metrics updated: ${metrics_dir%/}/frameflow_backup.prom"

360
scripts/rehearse-restore.sh Executable file
View File

@@ -0,0 +1,360 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat >&2 <<'EOF'
Usage: rehearse-restore.sh [--keep-on-failure] <frameflow-backup-directory>
Restores a verified FrameFlow backup into isolated, randomly named Docker
resources. No host ports are published. Resources are removed automatically
unless --keep-on-failure is supplied and the rehearsal fails.
EOF
exit "${1:-2}"
}
fail() {
echo "Restore rehearsal failed: $1" >&2
exit 1
}
keep_on_failure=0
while [[ $# -gt 0 ]]; do
case "$1" in
--keep-on-failure)
keep_on_failure=1
shift
;;
--help|-h)
usage 0
;;
--)
shift
break
;;
-*)
usage
;;
*)
break
;;
esac
done
[[ $# -eq 1 ]] || usage
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
backup_input="$1"
timeout_seconds="${FRAMEFLOW_RESTORE_TIMEOUT_SECONDS:-120}"
metrics_dir="${FRAMEFLOW_RESTORE_METRICS_DIR:-${FRAMEFLOW_BACKUP_METRICS_DIR:-/var/lib/frameflow/metrics}}"
started_at_seconds="$(date +%s)"
metrics_tmp=""
[[ "${timeout_seconds}" =~ ^[0-9]+$ ]] || fail "FRAMEFLOW_RESTORE_TIMEOUT_SECONDS must be an integer"
(( timeout_seconds >= 5 && timeout_seconds <= 3600 )) \
|| fail "FRAMEFLOW_RESTORE_TIMEOUT_SECONDS must be between 5 and 3600"
# Verification deliberately precedes every Docker call.
"${script_dir}/verify-backup.sh" "${backup_input}"
backup_dir="$(cd "${backup_input}" && pwd -P)"
command -v docker >/dev/null 2>&1 || fail "docker is required"
docker version >/dev/null 2>&1 || fail "the Docker engine is unavailable"
random_hex() {
local bytes="$1"
local value
value="$(LC_ALL=C od -An -N "${bytes}" -tx1 /dev/urandom | tr -d '[:space:]')"
[[ "${value}" =~ ^[0-9a-f]+$ ]] || fail "could not generate a random resource identifier"
printf '%s' "${value}"
}
timestamp="$(date -u +%Y%m%d%H%M%S)"
suffix="$(random_hex 4)"
run_id="${timestamp}-${suffix}"
prefix="frameflow-restore-${run_id}"
label_flag="com.frameflow.restore-rehearsal"
label_id="com.frameflow.restore-rehearsal.id"
expected_labels="true|${run_id}"
labels=(--label "${label_flag}=true" --label "${label_id}=${run_id}")
network_name="${prefix}-network"
postgres_volume="${prefix}-postgres"
redis_volume="${prefix}-redis"
minio_volume="${prefix}-minio"
postgres_container="${prefix}-postgres"
redis_container="${prefix}-redis"
minio_container="${prefix}-minio"
redis_extract_container="${prefix}-redis-extract"
minio_extract_container="${prefix}-minio-extract"
minio_check_container="${prefix}-minio-check"
postgres_image="postgres:16-alpine"
redis_image="redis:7.4-alpine"
alpine_image="alpine:3.20"
minio_image="minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e"
mc_image="minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727"
postgres_db="frameflow"
postgres_user="frameflow_restore"
postgres_password="$(random_hex 24)"
redis_password="$(random_hex 24)"
minio_access_key="restore${suffix}"
minio_secret_key="$(random_hex 32)"
resources_cleaned=0
resource_labels() {
local kind="$1"
local name="$2"
case "${kind}" in
container)
docker container inspect --format "{{ index .Config.Labels \"${label_flag}\" }}|{{ index .Config.Labels \"${label_id}\" }}" "${name}" 2>/dev/null
;;
network|volume)
docker "${kind}" inspect --format "{{ index .Labels \"${label_flag}\" }}|{{ index .Labels \"${label_id}\" }}" "${name}" 2>/dev/null
;;
*)
return 2
;;
esac
}
remove_labeled_resource() {
local kind="$1"
local name="$2"
local actual
if ! actual="$(resource_labels "${kind}" "${name}")"; then
return 0
fi
if [[ "${actual}" != "${expected_labels}" ]]; then
echo "Refusing to remove ${kind} ${name}: rehearsal label mismatch" >&2
return 1
fi
case "${kind}" in
container) docker rm -f "${name}" >/dev/null ;;
network) docker network rm "${name}" >/dev/null ;;
volume) docker volume rm "${name}" >/dev/null ;;
esac
}
cleanup_resources() {
local result=0
local name
for name in \
"${minio_check_container}" \
"${minio_extract_container}" \
"${redis_extract_container}" \
"${minio_container}" \
"${redis_container}" \
"${postgres_container}"; do
if ! remove_labeled_resource container "${name}"; then
result=1
fi
done
if ! remove_labeled_resource network "${network_name}"; then
result=1
fi
for name in "${minio_volume}" "${redis_volume}" "${postgres_volume}"; do
if ! remove_labeled_resource volume "${name}"; then
result=1
fi
done
return "${result}"
}
cleanup_on_exit() {
local status=$?
trap - EXIT
if [[ -n "${metrics_tmp}" && -f "${metrics_tmp}" ]]; then
rm -f "${metrics_tmp}"
fi
if (( status != 0 && keep_on_failure == 1 )); then
cat >&2 <<EOF
Restore rehearsal resources retained for diagnosis:
network: ${network_name}
volumes: ${postgres_volume}, ${redis_volume}, ${minio_volume}
containers: ${postgres_container}, ${redis_container}, ${minio_container}
Remove them only after confirming both labels ${label_flag}=true and ${label_id}=${run_id}.
EOF
exit "${status}"
fi
if (( resources_cleaned == 0 )); then
if ! cleanup_resources && (( status == 0 )); then
status=1
fi
fi
exit "${status}"
}
trap cleanup_on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
wait_for_postgres() {
local deadline=$((SECONDS + timeout_seconds))
until docker exec "${postgres_container}" pg_isready -U "${postgres_user}" -d "${postgres_db}" >/dev/null 2>&1; do
(( SECONDS < deadline )) || fail "PostgreSQL did not become ready within ${timeout_seconds} seconds"
sleep 1
done
}
wait_for_redis() {
local deadline=$((SECONDS + timeout_seconds))
until docker exec "${redis_container}" redis-cli --no-auth-warning -a "${redis_password}" ping 2>/dev/null | grep -q '^PONG$'; do
(( SECONDS < deadline )) || fail "Redis did not become ready within ${timeout_seconds} seconds"
sleep 1
done
}
wait_for_minio() {
local deadline=$((SECONDS + timeout_seconds))
until docker exec "${minio_container}" curl -fsS http://127.0.0.1:9000/minio/health/live >/dev/null 2>&1; do
(( SECONDS < deadline )) || fail "MinIO did not become ready within ${timeout_seconds} seconds"
sleep 1
done
}
echo "Creating isolated restore resources for rehearsal ${run_id}..."
docker network create --internal "${labels[@]}" "${network_name}" >/dev/null
docker volume create "${labels[@]}" "${postgres_volume}" >/dev/null
docker volume create "${labels[@]}" "${redis_volume}" >/dev/null
docker volume create "${labels[@]}" "${minio_volume}" >/dev/null
echo "Restoring PostgreSQL into an empty rehearsal volume..."
docker run -d \
--name "${postgres_container}" \
--network "${network_name}" \
"${labels[@]}" \
-e POSTGRES_DB="${postgres_db}" \
-e POSTGRES_USER="${postgres_user}" \
-e POSTGRES_PASSWORD="${postgres_password}" \
-v "${postgres_volume}:/var/lib/postgresql/data" \
"${postgres_image}" >/dev/null
wait_for_postgres
docker exec -i "${postgres_container}" \
pg_restore -U "${postgres_user}" -d "${postgres_db}" \
--exit-on-error --no-owner --no-privileges \
< "${backup_dir}/postgres.dump"
core_tables_ok="$(docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \
"select case when to_regclass('drizzle.__drizzle_migrations') is not null
and to_regclass('public.users') is not null
and to_regclass('public.workspaces') is not null
and to_regclass('public.projects') is not null
and to_regclass('public.episodes') is not null
and to_regclass('public.assets') is not null
and to_regclass('public.asset_versions') is not null
and to_regclass('public.shots') is not null
and to_regclass('public.shot_versions') is not null
and to_regclass('public.generation_jobs') is not null
and to_regclass('public.renders') is not null
and to_regclass('public.provider_verifications') is not null
then 'ok' else 'missing' end")"
[[ "${core_tables_ok}" == "ok" ]] || fail "PostgreSQL restore is missing required tables"
docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \
"select count(*) from users;
select count(*) from workspaces;
select count(*) from projects;
select count(*) from episodes;
select count(*) from assets;
select count(*) from shots;
select count(*) from generation_jobs;
select count(*) from renders;
select count(*) from provider_verifications;" >/dev/null
migration_count="$(docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \
'select count(*) from drizzle.__drizzle_migrations')"
table_count="$(docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \
"select count(*) from pg_tables where schemaname in ('public', 'drizzle')")"
[[ "${migration_count}" =~ ^[0-9]+$ && "${table_count}" =~ ^[0-9]+$ ]] \
|| fail "PostgreSQL verification returned invalid counts"
(( migration_count > 0 )) || fail "PostgreSQL migration history is empty"
echo "Restoring Redis persistence into an empty rehearsal volume..."
docker run --rm \
--name "${redis_extract_container}" \
"${labels[@]}" \
-v "${redis_volume}:/target" \
--mount "type=bind,src=${backup_dir},dst=/backup,readonly" \
--entrypoint /bin/sh \
"${alpine_image}" -ec \
'test -z "$(find /target -mindepth 1 -maxdepth 1 -print -quit)" && tar -xzf /backup/redis-data.tar.gz -C /target'
docker run -d \
--name "${redis_container}" \
--network "${network_name}" \
"${labels[@]}" \
-v "${redis_volume}:/data" \
"${redis_image}" redis-server --appendonly yes --requirepass "${redis_password}" >/dev/null
wait_for_redis
redis_key_count="$(docker exec "${redis_container}" redis-cli --no-auth-warning -a "${redis_password}" --raw DBSIZE)"
redis_persistence="$(docker exec "${redis_container}" redis-cli --no-auth-warning -a "${redis_password}" --raw INFO persistence | tr -d '\r')"
[[ "${redis_key_count}" =~ ^[0-9]+$ ]] || fail "Redis verification returned an invalid key count"
grep -q '^loading:0$' <<< "${redis_persistence}" || fail "Redis is still loading restored persistence"
grep -q '^aof_last_write_status:ok$' <<< "${redis_persistence}" || fail "Redis AOF persistence is not healthy"
echo "Restoring MinIO data into an empty rehearsal volume..."
docker run --rm \
--name "${minio_extract_container}" \
"${labels[@]}" \
-v "${minio_volume}:/target" \
--mount "type=bind,src=${backup_dir},dst=/backup,readonly" \
--entrypoint /bin/sh \
"${alpine_image}" -ec \
'test -z "$(find /target -mindepth 1 -maxdepth 1 -print -quit)" && tar -xzf /backup/minio-data.tar.gz -C /target'
docker run -d \
--name "${minio_container}" \
--network "${network_name}" \
"${labels[@]}" \
-e MINIO_ROOT_USER="${minio_access_key}" \
-e MINIO_ROOT_PASSWORD="${minio_secret_key}" \
-v "${minio_volume}:/data" \
"${minio_image}" server /data --console-address :9001 >/dev/null
wait_for_minio
minio_object_count="$(docker run --rm \
--name "${minio_check_container}" \
--network "${network_name}" \
"${labels[@]}" \
-e MC_HOST_RESTORE="http://${minio_access_key}:${minio_secret_key}@${minio_container}:9000" \
--entrypoint /bin/sh \
"${mc_image}" -ec \
'mc admin info RESTORE >/dev/null && mc ls RESTORE >/dev/null && mc ls --recursive RESTORE | wc -l | tr -d "[:space:]"')"
[[ "${minio_object_count}" =~ ^[0-9]+$ ]] || fail "MinIO verification returned an invalid object count"
echo "Cleaning isolated restore resources..."
cleanup_resources || fail "one or more rehearsal resources failed label verification or cleanup"
resources_cleaned=1
completed_at_seconds="$(date +%s)"
mkdir -p "${metrics_dir}"
metrics_tmp="$(mktemp "${metrics_dir%/}/frameflow_restore.prom.tmp.XXXXXX")"
cat > "${metrics_tmp}" <<EOF
# HELP frameflow_restore_rehearsal_last_success_timestamp_seconds Unix timestamp of the last successful isolated restore rehearsal.
# TYPE frameflow_restore_rehearsal_last_success_timestamp_seconds gauge
frameflow_restore_rehearsal_last_success_timestamp_seconds ${completed_at_seconds}
# HELP frameflow_restore_rehearsal_last_duration_seconds Duration of the last successful isolated restore rehearsal.
# TYPE frameflow_restore_rehearsal_last_duration_seconds gauge
frameflow_restore_rehearsal_last_duration_seconds $((completed_at_seconds - started_at_seconds))
# HELP frameflow_restore_rehearsal_postgres_table_count Number of restored PostgreSQL application and migration tables.
# TYPE frameflow_restore_rehearsal_postgres_table_count gauge
frameflow_restore_rehearsal_postgres_table_count ${table_count}
# HELP frameflow_restore_rehearsal_redis_key_count Number of Redis keys observed after restore.
# TYPE frameflow_restore_rehearsal_redis_key_count gauge
frameflow_restore_rehearsal_redis_key_count ${redis_key_count}
# HELP frameflow_restore_rehearsal_minio_object_count Number of MinIO objects listed after restore.
# TYPE frameflow_restore_rehearsal_minio_object_count gauge
frameflow_restore_rehearsal_minio_object_count ${minio_object_count}
EOF
chmod 0644 "${metrics_tmp}"
mv -f "${metrics_tmp}" "${metrics_dir%/}/frameflow_restore.prom"
metrics_tmp=""
echo "Restore rehearsal passed: PostgreSQL ${table_count} tables, Redis ${redis_key_count} keys, MinIO ${minio_object_count} objects."
echo "Restore rehearsal metrics updated: ${metrics_dir%/}/frameflow_restore.prom"

View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
cat >&2 <<'EOF'
Usage: scheduled-maintenance.sh <backup|restore-latest>
Requires FRAMEFLOW_BACKUP_MOUNTPOINT and FRAMEFLOW_BACKUP_DESTINATION.
The destination must be a writable directory inside the active mount point.
EOF
exit "${1:-2}"
}
fail() {
echo "Scheduled maintenance failed: $1" >&2
exit 1
}
[[ $# -eq 1 ]] || usage
case "$1" in
backup|restore-latest) mode="$1" ;;
--help|-h) usage 0 ;;
*) usage ;;
esac
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mount_point="${FRAMEFLOW_BACKUP_MOUNTPOINT:-}"
backup_destination="${FRAMEFLOW_BACKUP_DESTINATION:-}"
[[ -n "${mount_point}" ]] || fail "FRAMEFLOW_BACKUP_MOUNTPOINT is required"
[[ -n "${backup_destination}" ]] || fail "FRAMEFLOW_BACKUP_DESTINATION is required"
for path in "${mount_point}" "${backup_destination}"; do
[[ "${path}" == /* ]] || fail "backup paths must be absolute: ${path}"
[[ "${path}" != "/" ]] || fail "the filesystem root cannot be used for backups"
[[ ! "${path}" =~ [[:space:]] ]] || fail "backup paths must not contain whitespace: ${path}"
[[ -d "${path}" ]] || fail "backup directory does not exist: ${path}"
[[ ! -L "${path}" ]] || fail "backup paths must not be symbolic links: ${path}"
done
mount_point="$(cd "${mount_point}" && pwd -P)"
backup_destination="$(cd "${backup_destination}" && pwd -P)"
if [[ "${backup_destination}" != "${mount_point}" && "${backup_destination}" != "${mount_point}/"* ]]; then
fail "backup destination must be inside FRAMEFLOW_BACKUP_MOUNTPOINT"
fi
[[ -w "${backup_destination}" ]] || fail "backup destination is not writable: ${backup_destination}"
command -v findmnt >/dev/null 2>&1 || fail "findmnt is required to verify the backup mount"
mounted_target="$(findmnt -rn -M "${mount_point}" -o TARGET 2>/dev/null)" \
|| fail "backup mount is not active: ${mount_point}"
[[ "${mounted_target}" == "${mount_point}" ]] \
|| fail "backup mount resolved to an unexpected target: ${mounted_target}"
case "${mode}" in
backup)
exec "${script_dir}/backup.sh" "${backup_destination}"
;;
restore-latest)
latest_name=""
latest_path=""
while IFS= read -r -d '' candidate; do
[[ ! -L "${candidate}" ]] || continue
candidate_name="${candidate##*/}"
[[ "${candidate_name}" =~ ^frameflow-[0-9]{8}T[0-9]{6}Z$ ]] || continue
if [[ -z "${latest_name}" || "${candidate_name}" > "${latest_name}" ]]; then
latest_name="${candidate_name}"
latest_path="${candidate}"
fi
done < <(find "${backup_destination}" -mindepth 1 -maxdepth 1 -type d -name 'frameflow-*' -print0)
[[ -n "${latest_path}" ]] || fail "no timestamped FrameFlow backup was found"
echo "Selected latest backup for restore rehearsal: ${latest_path}"
"${script_dir}/verify-backup.sh" "${latest_path}"
exec "${script_dir}/rehearse-restore.sh" "${latest_path}"
;;
esac

927
scripts/smoke-test.mjs Executable file
View File

@@ -0,0 +1,927 @@
#!/usr/bin/env node
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = fileURLToPath(import.meta.url)
const defaultImagePath = fileURLToPath(new URL('../public/assets/scene-rooftop.jpg', import.meta.url))
const terminalJobStatuses = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED'])
const billableAiAcknowledgement = 'I_ACCEPT_BILLABLE_AI_CHARGES'
export const requiredLiveAiWorkflowIds = ['script', 'storyboard', 'image-openai', 'image-replicate', 'tts', 'video', 'lipsync']
export class SmokeTestError extends Error {
constructor(message, options = {}) {
super(message, options)
this.name = 'SmokeTestError'
this.report = options.report
}
}
function required(value, name) {
if (typeof value !== 'string' || value.trim() === '') throw new SmokeTestError(`${name} is required`)
return value.trim()
}
function booleanValue(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase())
}
function integerValue(value, name, minimum, maximum) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new SmokeTestError(`${name} must be an integer from ${minimum} to ${maximum}`)
}
return parsed
}
function jsonObjectValue(value, name) {
if (value === undefined || value === null || String(value).trim() === '') return {}
let parsed
try {
parsed = JSON.parse(String(value))
} catch {
throw new SmokeTestError(`${name} must be valid JSON`)
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new SmokeTestError(`${name} must be a JSON object`)
}
return parsed
}
export function resolveEndpoints(value, options = {}) {
const input = required(value, 'FRAMEFLOW_SMOKE_BASE_URL')
let url
try {
url = new URL(input)
} catch {
throw new SmokeTestError('FRAMEFLOW_SMOKE_BASE_URL must be a valid URL')
}
if (url.username || url.password || url.search || url.hash) {
throw new SmokeTestError('Smoke-test base URL cannot contain credentials, a query, or a fragment')
}
if (!['http:', 'https:'].includes(url.protocol)) throw new SmokeTestError('Smoke-test base URL must use HTTP or HTTPS')
const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
if (url.protocol === 'http:' && (!options.allowHttp || !localHostnames.has(url.hostname))) {
throw new SmokeTestError('HTTPS is required; HTTP can only be enabled explicitly for localhost')
}
const path = url.pathname.replace(/\/+$/, '')
if (path && path !== '/api/v1') {
throw new SmokeTestError('Smoke-test base URL path must be empty or /api/v1')
}
const origin = url.origin
return { origin, apiBase: `${origin}/api/v1` }
}
export function parseSmokeOptions(argv = process.argv.slice(2), environment = process.env) {
const values = {}
const flags = new Set()
const valueOptions = new Set(['--base-url', '--email', '--workspace-id', '--image', '--timeout-seconds', '--ai-video-seconds'])
const flagOptions = new Set(['--allow-http', '--keep-project', '--include-ai', '--json', '--help'])
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (valueOptions.has(argument)) {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new SmokeTestError(`${argument} requires a value`)
values[argument] = value
index += 1
} else if (flagOptions.has(argument)) {
flags.add(argument)
} else {
throw new SmokeTestError(`Unknown option: ${argument}`)
}
}
if (flags.has('--help')) return { help: true }
const allowHttp = flags.has('--allow-http') || booleanValue(environment.FRAMEFLOW_SMOKE_ALLOW_HTTP)
const endpoints = resolveEndpoints(values['--base-url'] ?? environment.FRAMEFLOW_SMOKE_BASE_URL, { allowHttp })
const email = required(values['--email'] ?? environment.FRAMEFLOW_SMOKE_EMAIL, 'FRAMEFLOW_SMOKE_EMAIL').toLowerCase()
const password = required(environment.FRAMEFLOW_SMOKE_PASSWORD, 'FRAMEFLOW_SMOKE_PASSWORD')
const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_SMOKE_TIMEOUT_SECONDS ?? '180', 'timeout-seconds', 30, 900)
const includeAi = flags.has('--include-ai')
if (includeAi && environment.FRAMEFLOW_SMOKE_BILLABLE_AI_ACK !== billableAiAcknowledgement) {
throw new SmokeTestError(`--include-ai requires FRAMEFLOW_SMOKE_BILLABLE_AI_ACK=${billableAiAcknowledgement}`)
}
return {
...endpoints,
email,
password,
workspaceId: values['--workspace-id'] ?? environment.FRAMEFLOW_SMOKE_WORKSPACE_ID ?? null,
imagePath: resolve(values['--image'] ?? environment.FRAMEFLOW_SMOKE_IMAGE ?? defaultImagePath),
timeoutMs: timeoutSeconds * 1000,
requestTimeoutMs: Math.min(60_000, timeoutSeconds * 1000),
keepProject: flags.has('--keep-project') || booleanValue(environment.FRAMEFLOW_SMOKE_KEEP_PROJECT),
includeAi,
aiVideoSeconds: integerValue(values['--ai-video-seconds'] ?? environment.FRAMEFLOW_SMOKE_AI_VIDEO_SECONDS ?? '5', 'ai-video-seconds', 1, 30),
aiVideoParams: jsonObjectValue(environment.FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON, 'FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON'),
aiLipsyncParams: jsonObjectValue(environment.FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON, 'FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON'),
json: flags.has('--json'),
}
}
function safeError(error) {
const message = error instanceof Error ? error.message : String(error)
return message.replace(/([?&](?:X-Amz-[^=\s]+|token|signature)=)[^&\s]+/gi, '$1[redacted]').slice(0, 2000)
}
function assertCondition(condition, message) {
if (!condition) throw new SmokeTestError(message)
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex')
}
function imageContentType(path) {
const extension = extname(path).toLowerCase()
if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg'
if (extension === '.png') return 'image/png'
if (extension === '.webp') return 'image/webp'
throw new SmokeTestError('Smoke-test image must be JPEG, PNG, or WebP')
}
function responseBody(response) {
if (response.status === 204) return Promise.resolve(null)
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) return response.json()
return response.text()
}
async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs)
try {
return await fetchImpl(url, { ...options, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
function createApiClient(options, fetchImpl) {
let accessToken = null
const cookies = new Map()
function captureCookies(headers) {
const values = typeof headers.getSetCookie === 'function'
? headers.getSetCookie()
: headers.get('set-cookie') ? [headers.get('set-cookie')] : []
for (const value of values) {
const pair = value?.split(';', 1)[0]
const separator = pair?.indexOf('=') ?? -1
if (!pair || separator < 1) continue
const name = pair.slice(0, separator).trim()
const cookieValue = pair.slice(separator + 1).trim()
if (!cookieValue || /(?:^|;)\s*max-age=0(?:;|$)/i.test(value)) cookies.delete(name)
else cookies.set(name, cookieValue)
}
}
async function raw(path, request = {}) {
const headers = new Headers(request.headers)
const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${options.apiBase}${path}`
const authenticatedApiRequest = request.auth !== false && (url === options.apiBase || url.startsWith(`${options.apiBase}/`))
if (accessToken && authenticatedApiRequest) headers.set('authorization', `Bearer ${accessToken}`)
if (authenticatedApiRequest && cookies.size > 0) {
headers.set('cookie', [...cookies].map(([name, value]) => `${name}=${value}`).join('; '))
}
let body = request.body
if (request.json !== undefined) {
headers.set('content-type', 'application/json')
body = JSON.stringify(request.json)
}
const response = await fetchWithTimeout(fetchImpl, url, {
method: request.method ?? 'GET',
headers,
body,
redirect: request.redirect ?? 'follow',
}, request.timeoutMs ?? options.requestTimeoutMs)
captureCookies(response.headers)
return response
}
async function request(path, input = {}) {
const response = await raw(path, input)
const body = await responseBody(response)
if (!response.ok) {
const message = body?.error?.message ?? (typeof body === 'string' && body) ?? `HTTP ${response.status}`
throw new SmokeTestError(`${input.method ?? 'GET'} ${path} failed (${response.status}): ${message}`)
}
return body
}
return {
raw,
request,
setAccessToken(value) { accessToken = value },
clearSession() {
accessToken = null
cookies.clear()
},
}
}
export async function waitForJob(getJob, jobId, options = {}) {
const timeoutMs = options.timeoutMs ?? 180_000
const pollIntervalMs = options.pollIntervalMs ?? 500
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)))
const now = options.now ?? Date.now
const startedAt = now()
let lastStatus = null
while (now() - startedAt <= timeoutMs) {
const job = await getJob(jobId)
lastStatus = job?.status ?? null
if (lastStatus === 'SUCCEEDED') return job
if (terminalJobStatuses.has(lastStatus)) {
throw new SmokeTestError(`Job ${jobId} ended in ${lastStatus}: ${job.errorMessage ?? job.errorCode ?? 'unknown error'}`)
}
await sleep(pollIntervalMs)
}
throw new SmokeTestError(`Job ${jobId} did not finish within ${timeoutMs}ms (last status: ${lastStatus ?? 'unknown'})`)
}
export async function waitForNotifications(getNotifications, resourceIds, options = {}) {
const timeoutMs = options.timeoutMs ?? 10_000
const pollIntervalMs = options.pollIntervalMs ?? 250
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)))
const now = options.now ?? Date.now
const expected = new Set(resourceIds)
const startedAt = now()
let lastItems = []
while (now() - startedAt <= timeoutMs) {
const page = await getNotifications()
lastItems = Array.isArray(page?.items) ? page.items : []
const matched = lastItems.filter((notification) => expected.has(notification.resourceId))
if (new Set(matched.map((notification) => notification.resourceId)).size === expected.size) return matched
await sleep(pollIntervalMs)
}
const matchedIds = new Set(lastItems.map((notification) => notification.resourceId))
const missing = [...expected].filter((resourceId) => !matchedIds.has(resourceId))
throw new SmokeTestError(`Notifications did not arrive within ${timeoutMs}ms (missing resources: ${missing.join(', ')})`)
}
function selectWorkspace(workspaces, requestedId) {
assertCondition(Array.isArray(workspaces) && workspaces.length > 0, 'Smoke account has no workspace membership')
const workspace = requestedId
? workspaces.find((candidate) => candidate.id === requestedId)
: workspaces.length === 1 ? workspaces[0] : null
if (!workspace) {
throw new SmokeTestError(requestedId
? `Smoke account is not a member of workspace ${requestedId}`
: 'Smoke account belongs to multiple workspaces; set FRAMEFLOW_SMOKE_WORKSPACE_ID')
}
assertCondition(['OWNER', 'ADMIN'].includes(workspace.role), 'Full smoke test requires an OWNER or ADMIN workspace role')
return workspace
}
async function downloadBytes(fetchImpl, url, timeoutMs, expectedType) {
const response = await fetchWithTimeout(fetchImpl, url, { redirect: 'follow' }, timeoutMs)
assertCondition(response.ok, `Media download failed with HTTP ${response.status}`)
const contentType = response.headers.get('content-type') ?? ''
if (expectedType) assertCondition(contentType.toLowerCase().includes(expectedType), `Unexpected media content type: ${contentType || 'missing'}`)
return Buffer.from(await response.arrayBuffer())
}
function readinessSummary(readiness) {
return {
infrastructure: readiness.infrastructure,
ai: {
status: readiness.ai?.status ?? 'UNKNOWN',
verificationMode: readiness.ai?.verificationMode ?? 'UNKNOWN',
summary: readiness.ai?.summary ?? null,
workflows: (readiness.ai?.workflows ?? []).map((workflow) => ({
id: workflow.id,
provider: workflow.provider,
model: workflow.model,
status: workflow.status,
})),
providers: (readiness.ai?.providers ?? []).map((provider) => ({
id: provider.id,
status: provider.status,
verification: provider.verification,
configuredCapabilities: provider.configuredCapabilities,
totalCapabilities: provider.totalCapabilities,
})),
},
}
}
export function assertLiveAiReady(readiness, minimumCredits = 700, billing = null) {
const capabilityRows = [
...(readiness?.ai?.providers ?? []).flatMap((provider) => provider.capabilities ?? []),
...(readiness?.ai?.workflows ?? []),
]
const workflows = new Map(capabilityRows.map((workflow) => [workflow.id, workflow]))
const missing = requiredLiveAiWorkflowIds.filter((id) => workflows.get(id)?.status !== 'CONFIGURED')
if (missing.length > 0) {
const details = missing.map((id) => {
const workflow = workflows.get(id)
const requirements = workflow?.missingRequirements?.join(', ')
return requirements ? `${id} (${requirements})` : id
})
throw new SmokeTestError(`Live AI smoke test requires configured workflows: ${details.join('; ')}`)
}
if (billing && Number(billing.remainingCredits) < minimumCredits) {
throw new SmokeTestError(`Live AI smoke test requires at least ${minimumCredits} remaining credits`)
}
return requiredLiveAiWorkflowIds.map((id) => {
const workflow = workflows.get(id)
return { id, provider: workflow.provider, model: workflow.model }
})
}
export async function runProductionSmoke(options, dependencies = {}) {
const fetchImpl = dependencies.fetch ?? fetch
const readFileImpl = dependencies.readFile ?? readFile
const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)))
const now = dependencies.now ?? Date.now
const log = dependencies.log ?? ((message) => console.error(message))
const api = createApiClient(options, fetchImpl)
const startedAt = now()
const runId = randomUUID()
const marker = `FRAMEFLOW_SMOKE_${runId}`
const report = {
status: 'running',
runId,
baseUrl: options.origin,
startedAt: new Date(startedAt).toISOString(),
workspaceId: null,
projectId: null,
projectArchived: false,
checks: [],
services: null,
billing: null,
aiVerification: {
mode: options.includeAi ? 'LIVE_PROVIDER_CALLS' : 'CONFIGURATION_ONLY',
jobs: [],
artifacts: {},
},
artifacts: {},
}
let projectId = null
let loggedIn = false
let smokeSessionId = null
let primaryError = null
async function step(id, label, action, summarize) {
const stepStartedAt = now()
log(`[...] ${label}`)
try {
const value = await action()
const check = { id, label, status: 'passed', durationMs: Math.max(0, now() - stepStartedAt) }
const details = summarize?.(value)
if (details !== undefined) check.details = details
report.checks.push(check)
log(`[ ok] ${label} (${check.durationMs}ms)`)
return value
} catch (error) {
const check = { id, label, status: 'failed', durationMs: Math.max(0, now() - stepStartedAt), error: safeError(error) }
report.checks.push(check)
log(`[fail] ${label}: ${check.error}`)
throw error
}
}
async function runAiJob(projectIdForJob, episodeIdForJob, type, input) {
const queued = await api.request(`/projects/${projectIdForJob}/jobs`, {
method: 'POST',
json: {
type,
episodeId: episodeIdForJob,
idempotencyKey: `smoke-ai-${runId}-${report.aiVerification.jobs.length}-${type.toLowerCase()}`,
input,
maxAttempts: 1,
},
})
const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, {
timeoutMs: options.timeoutMs,
sleep,
})
assertCondition(['openai', 'replicate'].includes(job.provider), `${type} did not record a live AI provider`)
assertCondition(job.providerModel, `${type} did not record a provider model`)
report.aiVerification.jobs.push({ id: job.id, type, provider: job.provider, model: job.providerModel })
return job
}
try {
await step('liveness', 'API liveness', async () => {
const response = await fetchWithTimeout(fetchImpl, `${options.origin}/health/live`, {}, options.requestTimeoutMs)
const body = await responseBody(response)
assertCondition(response.ok && body?.status === 'ok', `Liveness failed with HTTP ${response.status}`)
return body
})
await step('readiness', 'API dependency readiness', async () => {
const response = await fetchWithTimeout(fetchImpl, `${options.origin}/health/ready`, {}, options.requestTimeoutMs)
const body = await responseBody(response)
assertCondition(response.ok && body?.status === 'ready', `Readiness failed with HTTP ${response.status}`)
const unavailable = Object.entries(body.dependencies ?? {}).filter(([, status]) => status !== 'ok')
assertCondition(unavailable.length === 0, `Unavailable dependencies: ${unavailable.map(([name]) => name).join(', ')}`)
return body
}, (body) => body.dependencies)
const session = await step('login', 'Dedicated smoke-account login', async () => {
const value = await api.request('/auth/login', {
method: 'POST',
auth: false,
json: { email: options.email, password: options.password },
})
assertCondition(value?.accessToken && value?.user?.id, 'Login response is missing the authenticated session')
api.setAccessToken(value.accessToken)
loggedIn = true
const sessions = await api.request('/auth/sessions')
const currentSession = sessions.find((candidate) => candidate.current)
assertCondition(currentSession?.id, 'Login refresh session cookie was not retained by the smoke runner')
smokeSessionId = currentSession.id
return value
}, (value) => ({ userId: value.user.id, sessionId: smokeSessionId, workspaceCount: value.workspaces.length }))
const workspace = selectWorkspace(session.workspaces, options.workspaceId)
report.workspaceId = workspace.id
const serviceReadiness = await step('service-readiness', 'Worker, storage, FFmpeg, and AI configuration', async () => {
const value = await api.request(`/workspaces/${workspace.id}/service-readiness`)
assertCondition(value?.infrastructure?.status === 'AVAILABLE', 'Authenticated service readiness is degraded')
assertCondition(value.infrastructure.available === value.infrastructure.total, 'Not every production service is available')
return value
}, (value) => ({
infrastructure: value.infrastructure.services.map((service) => ({ id: service.id, status: service.status, latencyMs: service.latencyMs })),
ai: value.ai.summary,
}))
report.services = readinessSummary(serviceReadiness)
const billingReadModel = await step('billing', 'Billing and usage read model', async () => {
const value = await api.request(`/workspaces/${workspace.id}/billing/summary`)
assertCondition(value?.plan && value?.provider?.status, 'Billing summary is incomplete')
return value
}, (value) => ({ plan: value.plan, provider: value.provider.status, webhookConfigured: value.provider.webhookConfigured, remainingCredits: value.remainingCredits }))
report.billing = {
plan: billingReadModel.plan,
provider: billingReadModel.provider.status,
webhookConfigured: billingReadModel.provider.webhookConfigured,
remainingCredits: billingReadModel.remainingCredits,
}
if (options.includeAi) {
await step('ai-preflight', 'Billable AI authorization, configuration, and credit preflight', async () => {
const workflows = assertLiveAiReady(serviceReadiness, 700, billingReadModel)
return { acknowledgement: 'accepted', minimumCredits: 700, workflows }
}, (value) => value)
}
const project = await step('project', 'Project persistence', async () => api.request(`/workspaces/${workspace.id}/projects`, {
method: 'POST',
json: {
name: `[SMOKE] ${new Date(startedAt).toISOString()} ${runId.slice(0, 8)}`,
logline: 'Automated production smoke test. Safe to archive after completion.',
genre: 'SMOKE_TEST',
visualBible: { smokeTest: { runId, createdAt: new Date(startedAt).toISOString() } },
},
}), (value) => ({ projectId: value.id }))
projectId = project.id
report.projectId = projectId
const episode = await step('script', 'Episode and immutable script persistence', async () => {
const createdEpisode = await api.request(`/projects/${projectId}/episodes`, {
method: 'POST',
json: { episodeNumber: 1, title: 'Production smoke episode', synopsis: marker, targetDurationSeconds: 15 },
})
const script = await api.request(`/episodes/${createdEpisode.id}/scripts`, {
method: 'POST',
json: {
title: 'Production smoke script',
content: { smokeTest: true, runId, scenes: [{ number: 1, text: marker }] },
plainText: `SCENE 1 - SMOKE TEST\n${marker}`,
changeSummary: 'Production smoke test',
},
})
const persisted = await api.request(`/episodes/${createdEpisode.id}`)
assertCondition(persisted.currentScript?.id === script.id, 'Current script version was not persisted')
assertCondition(persisted.currentScript?.plainText?.includes(marker), 'Persisted script marker is missing')
return { episode: createdEpisode, script }
}, (value) => ({ episodeId: value.episode.id, scriptVersion: value.script.version }))
const sourceImage = await readFileImpl(options.imagePath)
const sourceHash = sha256(sourceImage)
const contentType = imageContentType(options.imagePath)
const imageAssetVersion = await step('upload', 'Object upload and signed download integrity', async () => {
const form = new FormData()
form.append('file', new Blob([sourceImage], { type: contentType }), basename(options.imagePath))
const upload = await api.request(`/projects/${projectId}/uploads`, { method: 'POST', body: form })
assertCondition(upload.byteSize === sourceImage.length, 'Uploaded object byte size does not match the source')
const asset = await api.request(`/projects/${projectId}/assets`, {
method: 'POST',
json: { type: 'SHOT', name: `Smoke source ${runId.slice(0, 8)}`, description: marker, tags: ['smoke-test'], metadata: { smokeRunId: runId } },
})
const version = await api.request(`/assets/${asset.id}/versions`, {
method: 'POST',
json: { storageKey: upload.key, mimeType: upload.contentType, byteSize: upload.byteSize, prompt: marker, provider: 'smoke-test', providerModel: 'uploaded-source-v1' },
})
const signed = await api.request(`/assets/${asset.id}/versions/${version.id}/url`)
const downloaded = await downloadBytes(fetchImpl, signed.url, options.requestTimeoutMs, contentType)
assertCondition(sha256(downloaded) === sourceHash, 'Signed object download does not match the uploaded image')
return { asset, version, upload }
}, (value) => ({ assetId: value.asset.id, assetVersionId: value.version.id, byteSize: value.upload.byteSize, sha256: sourceHash }))
const shot = await step('shot', 'Shot and immutable media-version persistence', async () => {
const created = await api.request(`/episodes/${episode.episode.id}/shots`, {
method: 'POST',
json: { shotNumber: 1, sceneNumber: 1, title: 'Smoke render shot', description: marker, shotType: '全景', cameraMotion: '静止', durationMs: 2000, characterAssetIds: [], sortOrder: 100 },
})
const version = await api.request(`/shots/${created.id}/versions`, {
method: 'POST',
json: { imageAssetVersionId: imageAssetVersion.version.id, subtitleText: marker, generationParams: { smokeRunId: runId } },
})
const persisted = await api.request(`/shots/${created.id}`)
assertCondition(persisted.versions?.[0]?.id === version.id, 'Current shot version was not persisted')
return { shot: created, version }
}, (value) => ({ shotId: value.shot.id, shotVersion: value.version.version }))
await step('queue', 'Redis/BullMQ worker execution', async () => {
const queued = await api.request(`/projects/${projectId}/jobs`, {
method: 'POST',
json: { type: 'PIPELINE_HEALTHCHECK', episodeId: episode.episode.id, idempotencyKey: `smoke-health-${runId}`, input: { marker }, maxAttempts: 1 },
})
const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep })
assertCondition(job.output?.ok === true && job.output?.echo?.marker === marker, 'Worker healthcheck output is invalid')
return job
}, (job) => ({ jobId: job.id, provider: job.provider, providerModel: job.providerModel }))
const renderResult = await step('render', 'FFmpeg render and immutable source manifest', async () => {
const queued = await api.request(`/episodes/${episode.episode.id}/renders`, {
method: 'POST',
json: {
idempotencyKey: `smoke-render-${runId}`,
settings: { width: 640, height: 360, fps: 24, quality: 'draft', burnSubtitles: false, normalizeAudio: false, musicVolume: 0, subtitleStyle: { fontSize: 20, textColor: '#FFFFFF', outlineColor: '#000000', outlineWidth: 2, bottomMargin: 24, bold: true } },
},
})
const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep })
const render = await api.request(`/renders/${queued.render.id}`)
assertCondition(render.status === 'REVIEW', `Render ended in unexpected status ${render.status}`)
assertCondition(render.sourceShotCount === 1, 'Render source manifest does not contain exactly one shot')
assertCondition(render.subtitleFormats?.includes('srt') && render.subtitleFormats?.includes('vtt'), 'Render subtitle sidecars are missing')
assertCondition(render.downloadUrl, 'Render download URL is missing')
const video = await downloadBytes(fetchImpl, render.downloadUrl, options.requestTimeoutMs, 'video/mp4')
assertCondition(video.length > 1000, 'Rendered video is unexpectedly small')
return { render, job, videoHash: sha256(video), videoBytes: video.length }
}, (value) => ({ renderId: value.render.id, jobId: value.job.id, byteSize: value.videoBytes, subtitleFormats: value.render.subtitleFormats, sourceShotCount: value.render.sourceShotCount }))
report.artifacts.renderId = renderResult.render.id
report.artifacts.videoSha256 = renderResult.videoHash
await step('subtitles', 'SRT and VTT sidecar downloads', async () => {
const results = {}
for (const format of ['srt', 'vtt']) {
const subtitle = await api.request(`/renders/${renderResult.render.id}/subtitles/${format}`)
const bytes = await downloadBytes(fetchImpl, subtitle.downloadUrl, options.requestTimeoutMs)
const text = bytes.toString('utf8')
assertCondition(text.includes(marker), `${format.toUpperCase()} subtitle does not contain the smoke marker`)
results[format] = { bytes: bytes.length, sha256: sha256(bytes) }
}
return results
}, (value) => value)
await step('review', 'Render review and approval', async () => {
const review = await api.request(`/workspaces/${workspace.id}/reviews`, {
method: 'POST',
json: { targetType: 'RENDER', targetId: renderResult.render.id, assignedToId: session.user.id, summary: `Automated approval for ${marker}` },
})
const decision = await api.request(`/reviews/${review.id}/decision`, {
method: 'POST',
json: { status: 'APPROVED', summary: 'Production smoke render passed automated checks.' },
})
const approved = await api.request(`/renders/${renderResult.render.id}`)
assertCondition(decision.status === 'APPROVED' && approved.status === 'APPROVED', 'Render approval was not persisted')
return { review, approved }
}, (value) => ({ reviewId: value.review.id, renderStatus: value.approved.status }))
const deliveryResult = await step('delivery', 'Approved download delivery through the worker', async () => {
const queued = await api.request(`/renders/${renderResult.render.id}/deliveries`, {
method: 'POST',
json: { platform: 'DOWNLOAD', destination: { smokeRunId: runId } },
})
const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep })
const delivery = await api.request(`/deliveries/${queued.delivery.id}`)
assertCondition(delivery.status === 'PUBLISHED' && delivery.downloadUrl, 'Download delivery was not published')
const redirect = await api.raw(`/deliveries/${delivery.id}/download`, { redirect: 'manual' })
assertCondition([301, 302, 303, 307, 308].includes(redirect.status), `Delivery endpoint returned HTTP ${redirect.status} instead of a redirect`)
const location = redirect.headers.get('location')
assertCondition(location, 'Delivery redirect is missing its signed location')
const video = await downloadBytes(fetchImpl, location, options.requestTimeoutMs, 'video/mp4')
assertCondition(sha256(video) === renderResult.videoHash, 'Delivered video differs from the approved render')
return { delivery, job, bytes: video.length }
}, (value) => ({ deliveryId: value.delivery.id, jobId: value.job.id, status: value.delivery.status, byteSize: value.bytes }))
report.artifacts.deliveryId = deliveryResult.delivery.id
await step('notifications', 'Persistent job notifications and read state', async () => {
const expectedJobIds = [renderResult.job.id, deliveryResult.job.id]
const rows = await waitForNotifications(
() => api.request(`/workspaces/${workspace.id}/notifications?limit=100`),
expectedJobIds,
{ timeoutMs: Math.min(options.timeoutMs, 10_000), sleep, now },
)
for (const jobId of expectedJobIds) {
const notification = rows.find((candidate) => candidate.resourceId === jobId)
assertCondition(notification?.kind === 'JOB_SUCCEEDED' && notification.tone === 'SUCCESS', `Successful job notification is invalid for ${jobId}`)
const expectedPage = jobId === deliveryResult.job.id ? 'publish' : 'jobs'
assertCondition(notification.page === expectedPage, `Job notification points to ${notification.page} instead of ${expectedPage}`)
const read = await api.request(`/notifications/${notification.id}`, { method: 'PATCH', json: { read: true } })
assertCondition(read.readAt, `Notification ${notification.id} did not persist its read state`)
}
return rows
}, (rows) => ({ notifications: rows.length, resources: rows.map((notification) => notification.resourceId) }))
await step('audit', 'Audit-log persistence', async () => {
const page = await api.request(`/workspaces/${workspace.id}/audit-logs?limit=100&actorId=${session.user.id}`)
const actions = new Set((page.items ?? []).map((item) => item.action))
for (const action of ['project.create', 'upload.create', 'render.create', 'review.decide', 'delivery.create']) {
assertCondition(actions.has(action), `Audit log is missing ${action}`)
}
return { actions: actions.size }
}, (value) => value)
if (options.includeAi) {
const aiSetup = await step('ai-setup', 'Live AI validation workspace data', async () => {
const aiEpisode = await api.request(`/projects/${projectId}/episodes`, {
method: 'POST',
json: {
episodeNumber: 2,
title: 'Live provider validation',
synopsis: `A single character says a short line in a controlled studio. ${marker}`,
targetDurationSeconds: 15,
},
})
const character = await api.request(`/projects/${projectId}/assets`, {
method: 'POST',
json: {
type: 'CHARACTER',
name: `Provider test character ${runId.slice(0, 8)}`,
description: 'Adult presenter, centered portrait, neutral studio lighting, production-safe wardrobe.',
tags: ['smoke-test', 'provider-validation'],
metadata: { smokeRunId: runId },
},
})
return { episode: aiEpisode, character }
}, (value) => ({ episodeId: value.episode.id, characterAssetId: value.character.id }))
const openAiImage = await step('ai-image-openai', 'OpenAI character image generation', async () => {
const job = await runAiJob(projectId, aiSetup.episode.id, 'ASSET_GENERATE', {
assetId: aiSetup.character.id,
prompt: `Clean comic-drama character reference sheet. Adult presenter, centered bust portrait, neutral gray studio, consistent facial landmarks. ${marker}`,
negativePrompt: 'text, watermark, extra limbs, child',
width: 1024,
height: 1024,
provider: 'openai',
referenceAssetVersionIds: [],
params: { consistencyStrength: 82 },
})
const asset = await api.request(`/assets/${aiSetup.character.id}`)
const version = asset.versions?.find((candidate) => candidate.id === job.output?.assetVersionId)
assertCondition(version?.storageKey && version.provider === 'openai', 'OpenAI image version was not persisted as current asset media')
const signed = await api.request(`/assets/${asset.id}/versions/${version.id}/url`)
const bytes = await downloadBytes(fetchImpl, signed.url, options.requestTimeoutMs, 'image/')
assertCondition(bytes.length > 1000, 'OpenAI image output is unexpectedly small')
return { job, asset, version, bytes }
}, (value) => ({ jobId: value.job.id, assetVersionId: value.version.id, model: value.job.providerModel, byteSize: value.bytes.length }))
report.aiVerification.artifacts.openAiImageAssetVersionId = openAiImage.version.id
const replicateImage = await step('ai-image-replicate', 'Replicate character-consistency variant', async () => {
const profile = await api.request(`/assets/${aiSetup.character.id}/consistency-profile`, {
method: 'PUT',
json: {
referenceAssetVersionIds: [openAiImage.version.id],
identityPrompt: 'Preserve facial proportions, eye shape, hairstyle, and adult age.',
costumePrompt: 'Preserve the dark production jacket and simple collar.',
stylePrompt: 'Polished cinematic comic-drama illustration.',
negativePrompt: 'identity drift, age change, child, text, watermark',
consistencyStrength: 90,
identityLocked: true,
},
})
assertCondition(profile.consistencyProfile?.identityLocked, 'Character identity profile was not locked')
const job = await runAiJob(projectId, aiSetup.episode.id, 'ASSET_GENERATE', {
assetId: aiSetup.character.id,
prompt: `The same adult presenter turns slightly toward camera under warm key light. ${marker}`,
negativePrompt: 'text, watermark',
width: 1024,
height: 1024,
provider: 'replicate',
referenceAssetVersionIds: [openAiImage.version.id],
params: { consistencyStrength: 90 },
})
const asset = await api.request(`/assets/${aiSetup.character.id}`)
const version = asset.versions?.find((candidate) => candidate.id === job.output?.assetVersionId)
const appliedProfiles = version?.generationParams?.appliedCharacterConsistencyProfiles
assertCondition(version?.storageKey && version.provider === 'replicate', 'Replicate image version was not persisted')
assertCondition(Array.isArray(appliedProfiles) && appliedProfiles.some((snapshot) => snapshot.assetId === aiSetup.character.id && snapshot.identityLocked), 'Replicate variant did not capture the locked character profile')
assertCondition(version.generationParams?.referenceAssetVersionIds?.includes(openAiImage.version.id), 'Replicate variant did not record its identity reference')
const signed = await api.request(`/assets/${asset.id}/versions/${version.id}/url`)
const bytes = await downloadBytes(fetchImpl, signed.url, options.requestTimeoutMs, 'image/')
assertCondition(bytes.length > 1000, 'Replicate image output is unexpectedly small')
return { job, asset, version, bytes }
}, (value) => ({ jobId: value.job.id, assetVersionId: value.version.id, model: value.job.providerModel, byteSize: value.bytes.length }))
report.aiVerification.artifacts.replicateImageAssetVersionId = replicateImage.version.id
await step('ai-script', 'OpenAI script generation and immutable persistence', async () => {
const job = await runAiJob(projectId, aiSetup.episode.id, 'SCRIPT_GENERATE', {
provider: 'openai',
includeExistingScript: false,
instruction: 'Write one production-safe interior scene for a 15-second Chinese comic drama. Use one adult presenter, one short spoken line, and a clear visual action.',
})
const episode = await api.request(`/episodes/${aiSetup.episode.id}`)
assertCondition(episode.currentScript?.id === job.output?.scriptVersionId, 'Generated script was not activated as the current version')
assertCondition(episode.currentScript?.plainText?.trim(), 'Generated script has no plain text')
return { job, script: episode.currentScript }
}, (value) => ({ jobId: value.job.id, scriptVersionId: value.script.id, version: value.script.version, model: value.job.providerModel }))
const storyboard = await step('ai-storyboard', 'OpenAI storyboard generation and shot persistence', async () => {
const job = await runAiJob(projectId, aiSetup.episode.id, 'STORYBOARD_GENERATE', {
provider: 'openai',
replaceExisting: false,
instruction: `Create the smallest viable storyboard for provider validation. Prefer exactly one shot featuring ${aiSetup.character.name}, with one short spoken line and a total duration near five seconds.`,
})
const rows = await api.request(`/episodes/${aiSetup.episode.id}/shots`)
assertCondition(Array.isArray(rows) && rows.length > 0, 'Generated storyboard contains no shots')
assertCondition(job.output?.shotCount === rows.length, 'Storyboard job output does not match persisted shots')
const [selected, ...extra] = rows
for (const row of extra) await api.request(`/shots/${row.shot.id}`, { method: 'DELETE' })
const validationText = 'FrameFlow AI provider validation passed.'
const version = await api.request(`/shots/${selected.shot.id}/versions`, {
method: 'POST',
json: {
imageAssetVersionId: replicateImage.version.id,
subtitleText: validationText,
generationParams: { smokeRunId: runId, source: 'live-provider-smoke' },
},
})
return { job, shot: selected.shot, version, removedShots: extra.length, validationText }
}, (value) => ({ jobId: value.job.id, shotId: value.shot.id, generatedShots: value.job.output.shotCount, removedShots: value.removedShots, model: value.job.providerModel }))
await step('ai-tts', 'OpenAI TTS generation and audio storage', async () => {
const job = await runAiJob(projectId, aiSetup.episode.id, 'TTS_GENERATE', {
shotId: storyboard.shot.id,
text: storyboard.validationText,
voice: 'alloy',
speed: 1,
provider: 'openai',
})
const media = await api.request(`/shots/${storyboard.shot.id}/media`)
assertCondition(job.output?.audioStorageKey && media.audioUrl, 'TTS output was not bound to the current shot version')
const bytes = await downloadBytes(fetchImpl, media.audioUrl, options.requestTimeoutMs, 'audio/')
assertCondition(bytes.length > 500, 'TTS audio output is unexpectedly small')
return { job, media, bytes }
}, (value) => ({ jobId: value.job.id, model: value.job.providerModel, byteSize: value.bytes.length }))
await step('ai-video', 'Replicate image-to-video generation and storage', async () => {
const job = await runAiJob(projectId, aiSetup.episode.id, 'VIDEO_GENERATE', {
shotId: storyboard.shot.id,
prompt: 'Subtle natural head turn and blinking, locked camera, stable adult character identity.',
imageAssetVersionId: replicateImage.version.id,
durationSeconds: options.aiVideoSeconds,
provider: 'replicate',
params: options.aiVideoParams,
})
const media = await api.request(`/shots/${storyboard.shot.id}/media`)
assertCondition(job.output?.videoStorageKey && media.videoUrl, 'Video output was not bound to the current shot version')
const bytes = await downloadBytes(fetchImpl, media.videoUrl, options.requestTimeoutMs, 'video/')
assertCondition(bytes.length > 1000, 'Generated video output is unexpectedly small')
return { job, media, bytes }
}, (value) => ({ jobId: value.job.id, model: value.job.providerModel, durationSeconds: options.aiVideoSeconds, byteSize: value.bytes.length }))
const lipsync = await step('ai-lipsync', 'Replicate lip-sync generation and media continuity', async () => {
const job = await runAiJob(projectId, aiSetup.episode.id, 'LIPSYNC_GENERATE', {
shotId: storyboard.shot.id,
provider: 'replicate',
params: options.aiLipsyncParams,
})
const media = await api.request(`/shots/${storyboard.shot.id}/media`)
assertCondition(job.output?.videoStorageKey && media.videoUrl && media.audioUrl, 'Lip-sync output did not preserve video and audio continuity')
const [video, audio] = await Promise.all([
downloadBytes(fetchImpl, media.videoUrl, options.requestTimeoutMs, 'video/'),
downloadBytes(fetchImpl, media.audioUrl, options.requestTimeoutMs, 'audio/'),
])
assertCondition(video.length > 1000 && audio.length > 500, 'Lip-sync media outputs are unexpectedly small')
return { job, media, video, audio }
}, (value) => ({ jobId: value.job.id, model: value.job.providerModel, videoBytes: value.video.length, audioBytes: value.audio.length }))
report.aiVerification.artifacts.lipsyncShotVersionId = lipsync.media.versionId
const aiRender = await step('ai-render', 'FFmpeg render from live AI media', async () => {
const queued = await api.request(`/episodes/${aiSetup.episode.id}/renders`, {
method: 'POST',
json: {
idempotencyKey: `smoke-ai-render-${runId}`,
settings: { width: 640, height: 360, fps: 24, quality: 'draft', burnSubtitles: false, normalizeAudio: false, musicVolume: 0, subtitleStyle: { fontSize: 20, textColor: '#FFFFFF', outlineColor: '#000000', outlineWidth: 2, bottomMargin: 24, bold: true } },
},
})
const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep })
const render = await api.request(`/renders/${queued.render.id}`)
assertCondition(job.status === 'SUCCEEDED' && render.status === 'REVIEW', 'AI media render did not finish in review state')
assertCondition(render.sourceShotCount === 1 && render.downloadUrl, 'AI media render source manifest is invalid')
const video = await downloadBytes(fetchImpl, render.downloadUrl, options.requestTimeoutMs, 'video/mp4')
assertCondition(video.length > 1000, 'AI media render is unexpectedly small')
return { job, render, video }
}, (value) => ({ jobId: value.job.id, renderId: value.render.id, sourceShotCount: value.render.sourceShotCount, byteSize: value.video.length }))
report.aiVerification.artifacts.renderId = aiRender.render.id
report.aiVerification.artifacts.videoSha256 = sha256(aiRender.video)
await step('ai-usage', 'Live AI usage-ledger reconciliation', async () => {
const billingAfter = await api.request(`/workspaces/${workspace.id}/billing/summary`)
const providerJobIds = new Set(report.aiVerification.jobs.map((job) => job.id))
const billedJobIds = new Set((billingAfter.recent ?? []).filter((entry) => providerJobIds.has(entry.jobId)).map((entry) => entry.jobId))
const missingJobIds = [...providerJobIds].filter((jobId) => !billedJobIds.has(jobId))
assertCondition(missingJobIds.length === 0, `Usage ledger is missing ${missingJobIds.length} live AI jobs`)
const creditsUsed = Number(billingAfter.usedCredits) - Number(billingReadModel.usedCredits)
assertCondition(creditsUsed > 0, 'Live AI provider calls did not increase recorded usage')
report.aiVerification.creditsUsed = creditsUsed
return { jobs: providerJobIds.size, ledgerJobs: billedJobIds.size, creditsUsed }
}, (value) => value)
}
} catch (error) {
primaryError = error
} finally {
if (projectId && !options.keepProject) {
try {
await step('cleanup', 'Archive smoke-test project', async () => {
const archived = await api.request(`/projects/${projectId}`, { method: 'DELETE' })
assertCondition(archived.status === 'ARCHIVED', 'Smoke project was not archived')
report.projectArchived = true
return archived
}, (value) => ({ projectId: value.id, status: value.status }))
} catch (cleanupError) {
if (!primaryError) primaryError = cleanupError
}
}
if (loggedIn) {
try {
await step('logout', 'Revoke smoke-account refresh session', async () => {
await api.request('/auth/logout', { method: 'POST' })
const sessions = await api.request('/auth/sessions')
assertCondition(!sessions.some((candidate) => candidate.id === smokeSessionId), 'Smoke refresh session remains active after logout')
api.clearSession()
return { sessionId: smokeSessionId, revoked: true }
}, (value) => value)
} catch (logoutError) {
if (!primaryError) primaryError = logoutError
}
}
}
report.status = primaryError ? 'failed' : 'passed'
report.completedAt = new Date(now()).toISOString()
report.durationMs = Math.max(0, now() - startedAt)
if (options.keepProject && projectId) report.projectArchived = false
if (primaryError) {
report.error = safeError(primaryError)
throw new SmokeTestError(report.error, { cause: primaryError, report })
}
return report
}
export function usage() {
return `FrameFlow production smoke test
Usage:
FRAMEFLOW_SMOKE_BASE_URL=https://studio.example.com \\
FRAMEFLOW_SMOKE_EMAIL=smoke@example.com \\
FRAMEFLOW_SMOKE_PASSWORD='...' \\
npm run smoke:production -- [options]
Options:
--base-url URL Application origin or /api/v1 URL
--email EMAIL Dedicated verified smoke-account email
--workspace-id UUID Required when the account has multiple workspaces
--image PATH JPEG, PNG, or WebP render source
--timeout-seconds N Per-job timeout from 30 to 900 (default: 180)
--include-ai Run live, billable OpenAI and Replicate validation
--ai-video-seconds N Live AI video duration from 1 to 30 (default: 5)
--keep-project Keep the generated smoke project instead of archiving it
--allow-http Allow HTTP only for localhost development
--json Print the complete report as JSON
--help Show this help
The password is accepted only through FRAMEFLOW_SMOKE_PASSWORD so it is not exposed in the process list.
Default mode never invokes AI provider jobs; configuration-only readiness is included in the report.
--include-ai starts real provider jobs that can incur charges. It is accepted only when
FRAMEFLOW_SMOKE_BILLABLE_AI_ACK=I_ACCEPT_BILLABLE_AI_CHARGES is also set, all seven AI
workflows are configured, and the workspace has at least 700 remaining credits. Provider
jobs use maxAttempts=1. Optional model-specific JSON objects can be supplied through
FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON and FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON.`
}
async function main() {
let options
try {
options = parseSmokeOptions()
if (options.help) {
console.log(usage())
return
}
const report = await runProductionSmoke(options)
if (options.json) console.log(JSON.stringify(report, null, 2))
else console.log(`Production smoke test passed in ${report.durationMs}ms. Project ${report.projectId}${report.projectArchived ? ' was archived' : ' was retained'}.`)
} catch (error) {
const report = error instanceof SmokeTestError ? error.report : null
if (options?.json && report) console.error(JSON.stringify(report, null, 2))
else console.error(`Production smoke test failed: ${safeError(error)}`)
process.exitCode = 1
}
}
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) await main()

305
scripts/stripe-smoke.mjs Executable file
View File

@@ -0,0 +1,305 @@
#!/usr/bin/env node
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import Stripe from 'stripe'
const scriptPath = fileURLToPath(import.meta.url)
export const requiredStripeWebhookEvents = [
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
]
export class StripeSmokeError extends Error {
constructor(message, options = {}) {
super(message, options)
this.name = 'StripeSmokeError'
}
}
function required(value, name) {
if (typeof value !== 'string' || value.trim() === '') throw new StripeSmokeError(`${name} is required`)
return value.trim()
}
function integerValue(value, name, minimum, maximum) {
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new StripeSmokeError(`${name} must be an integer from ${minimum} to ${maximum}`)
}
return parsed
}
function booleanValue(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase())
}
export function resolveStripeAppOrigin(value, options = {}) {
let url
try {
url = new URL(required(value, 'FRAMEFLOW_STRIPE_APP_ORIGIN'))
} catch (error) {
if (error instanceof StripeSmokeError) throw error
throw new StripeSmokeError('FRAMEFLOW_STRIPE_APP_ORIGIN must be a valid URL')
}
if (url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new StripeSmokeError('Stripe app origin must be a bare origin without credentials, path, query, or fragment')
}
if (!['http:', 'https:'].includes(url.protocol)) throw new StripeSmokeError('Stripe app origin must use HTTP or HTTPS')
const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]'])
if (url.protocol === 'http:' && (!options.allowHttp || !localHostnames.has(url.hostname))) {
throw new StripeSmokeError('HTTPS is required; HTTP can only be enabled explicitly for localhost')
}
return url.origin
}
export function usage() {
return `Usage: npm run smoke:stripe -- [options]
Performs read-only Stripe account, recurring Price, Product, Customer Portal,
and webhook-endpoint configuration checks. It never creates a customer,
Checkout Session, Portal Session, charge, subscription, or webhook event.
Required environment:
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
STRIPE_PRO_PRICE_ID
STRIPE_STUDIO_PRICE_ID
FRAMEFLOW_STRIPE_APP_ORIGIN (falls back to API_PUBLIC_ORIGIN or WEB_ORIGIN)
Optional environment:
STRIPE_PORTAL_CONFIGURATION_ID
FRAMEFLOW_STRIPE_SMOKE_TIMEOUT_SECONDS=30
Options:
--app-origin <origin>
--timeout-seconds <10-120>
--allow-http Loopback development only
--require-live Reject test keys and test-mode Stripe resources
--json
--help
The signing secret is never sent or printed. Stripe does not expose webhook
signing secrets through its API, so a passing report proves endpoint/event
configuration but not that STRIPE_WEBHOOK_SECRET matches that endpoint.
`
}
export function parseStripeSmokeOptions(argv = process.argv.slice(2), environment = process.env) {
const values = {}
const flags = new Set()
const valueOptions = new Set(['--app-origin', '--timeout-seconds'])
const flagOptions = new Set(['--allow-http', '--require-live', '--json', '--help'])
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (valueOptions.has(argument)) {
const value = argv[index + 1]
if (!value || value.startsWith('--')) throw new StripeSmokeError(`${argument} requires a value`)
values[argument] = value
index += 1
} else if (flagOptions.has(argument)) {
flags.add(argument)
} else {
throw new StripeSmokeError(`Unknown option: ${argument}`)
}
}
if (flags.has('--help')) return { help: true }
const secretKey = required(environment.STRIPE_SECRET_KEY, 'STRIPE_SECRET_KEY')
const keyMatch = /^(?:sk|rk)_(test|live)_/.exec(secretKey)
if (!keyMatch) throw new StripeSmokeError('STRIPE_SECRET_KEY must be a Stripe secret or restricted key')
const mode = keyMatch[1]
const requireLive = flags.has('--require-live') || booleanValue(environment.FRAMEFLOW_STRIPE_REQUIRE_LIVE)
if (requireLive && mode !== 'live') throw new StripeSmokeError('--require-live requires a live-mode Stripe key')
const webhookSecret = required(environment.STRIPE_WEBHOOK_SECRET, 'STRIPE_WEBHOOK_SECRET')
if (!webhookSecret.startsWith('whsec_')) throw new StripeSmokeError('STRIPE_WEBHOOK_SECRET must use the Stripe whsec_ format')
const proPriceId = required(environment.STRIPE_PRO_PRICE_ID, 'STRIPE_PRO_PRICE_ID')
const studioPriceId = required(environment.STRIPE_STUDIO_PRICE_ID, 'STRIPE_STUDIO_PRICE_ID')
if (!proPriceId.startsWith('price_') || !studioPriceId.startsWith('price_')) {
throw new StripeSmokeError('Stripe plan IDs must use the price_ format')
}
if (proPriceId === studioPriceId) throw new StripeSmokeError('PRO and STUDIO must use different Stripe Price IDs')
const portalConfigurationId = environment.STRIPE_PORTAL_CONFIGURATION_ID?.trim() || null
if (portalConfigurationId && !portalConfigurationId.startsWith('bpc_')) {
throw new StripeSmokeError('STRIPE_PORTAL_CONFIGURATION_ID must use the bpc_ format')
}
const allowHttp = flags.has('--allow-http') || booleanValue(environment.FRAMEFLOW_STRIPE_ALLOW_HTTP)
const originValue = values['--app-origin']
?? environment.FRAMEFLOW_STRIPE_APP_ORIGIN
?? environment.API_PUBLIC_ORIGIN
?? environment.WEB_ORIGIN?.split(',')[0]?.trim()
const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_STRIPE_SMOKE_TIMEOUT_SECONDS ?? '30', 'timeout-seconds', 10, 120)
return {
secretKey,
webhookSecretConfigured: true,
proPriceId,
studioPriceId,
portalConfigurationId,
appOrigin: resolveStripeAppOrigin(originValue, { allowHttp }),
mode,
requireLive,
timeoutMs: timeoutSeconds * 1000,
json: flags.has('--json'),
}
}
async function listStripeCollection(fetchPage, parameters = {}) {
const rows = []
let startingAfter
for (let page = 0; page < 20; page += 1) {
const response = await fetchPage({ ...parameters, limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}) })
if (!response || !Array.isArray(response.data)) throw new StripeSmokeError('Stripe returned an invalid list response')
rows.push(...response.data)
if (!response.has_more) return rows
startingAfter = response.data.at(-1)?.id
if (!startingAfter) throw new StripeSmokeError('Stripe pagination returned no continuation ID')
}
throw new StripeSmokeError('Stripe list exceeded the 2,000-resource verification limit')
}
function assertMode(resource, mode, label) {
if (typeof resource?.livemode !== 'boolean') throw new StripeSmokeError(`${label} did not report livemode`)
if (resource.livemode !== (mode === 'live')) throw new StripeSmokeError(`${label} does not match the Stripe key mode`)
}
async function verifyPrice(client, priceId, plan, mode) {
const price = await client.prices.retrieve(priceId, { expand: ['product'] })
if (price?.object !== 'price' || price.id !== priceId) throw new StripeSmokeError(`${plan} Stripe Price could not be retrieved`)
assertMode(price, mode, `${plan} Price`)
if (price.active !== true) throw new StripeSmokeError(`${plan} Stripe Price is not active`)
if (price.type !== 'recurring' || !price.recurring) throw new StripeSmokeError(`${plan} Stripe Price must be recurring`)
if (price.recurring.usage_type !== 'licensed') throw new StripeSmokeError(`${plan} Stripe Price must use licensed recurring usage`)
const product = price.product
if (!product || typeof product === 'string' || product.deleted || product.object !== 'product') {
throw new StripeSmokeError(`${plan} Stripe Product was not expanded or has been deleted`)
}
if (product.active !== true) throw new StripeSmokeError(`${plan} Stripe Product is not active`)
assertMode(product, mode, `${plan} Product`)
return {
plan,
priceId: price.id,
productId: product.id,
productName: product.name,
currency: price.currency,
unitAmount: price.unit_amount,
billingScheme: price.billing_scheme,
interval: price.recurring.interval,
intervalCount: price.recurring.interval_count,
}
}
async function verifyPortal(client, configurationId, mode) {
let configuration
if (configurationId) {
configuration = await client.billingPortal.configurations.retrieve(configurationId)
} else {
const configurations = await listStripeCollection(
(parameters) => client.billingPortal.configurations.list(parameters),
{ active: true },
)
configuration = configurations.find((candidate) => candidate.is_default === true)
}
if (!configuration || configuration.object !== 'billing_portal.configuration') {
throw new StripeSmokeError('No active default Stripe Customer Portal configuration was found')
}
if (configuration.active !== true) throw new StripeSmokeError('Stripe Customer Portal configuration is not active')
assertMode(configuration, mode, 'Customer Portal configuration')
return {
id: configuration.id,
isDefault: configuration.is_default === true,
active: true,
}
}
async function verifyWebhook(client, appOrigin, mode) {
const expectedUrl = `${appOrigin}/api/v1/billing/webhooks/stripe`
const endpoints = await listStripeCollection((parameters) => client.webhookEndpoints.list(parameters))
const endpoint = endpoints.find((candidate) => candidate.url === expectedUrl && candidate.status === 'enabled')
if (!endpoint) throw new StripeSmokeError(`No enabled Stripe webhook endpoint matches ${expectedUrl}`)
assertMode(endpoint, mode, 'Webhook endpoint')
const enabledEvents = new Set(endpoint.enabled_events ?? [])
const missingEvents = enabledEvents.has('*')
? []
: requiredStripeWebhookEvents.filter((event) => !enabledEvents.has(event))
if (missingEvents.length > 0) {
throw new StripeSmokeError(`Stripe webhook endpoint is missing events: ${missingEvents.join(', ')}`)
}
return {
id: endpoint.id,
url: endpoint.url,
status: endpoint.status,
requiredEvents: requiredStripeWebhookEvents,
}
}
export async function runStripeSmoke(options, dependencies = {}) {
const client = dependencies.client ?? new Stripe(options.secretKey, {
maxNetworkRetries: 1,
timeout: options.timeoutMs,
})
const account = await client.accounts.retrieve()
if (!account || account.object !== 'account' || typeof account.id !== 'string') {
throw new StripeSmokeError('Stripe account could not be retrieved')
}
if (options.requireLive && account.charges_enabled !== true) {
throw new StripeSmokeError('Stripe live charges are not enabled for this account')
}
if (options.requireLive && account.details_submitted !== true) {
throw new StripeSmokeError('Stripe account onboarding details are incomplete')
}
const [pro, studio, portal, webhook] = await Promise.all([
verifyPrice(client, options.proPriceId, 'PRO', options.mode),
verifyPrice(client, options.studioPriceId, 'STUDIO', options.mode),
verifyPortal(client, options.portalConfigurationId, options.mode),
verifyWebhook(client, options.appOrigin, options.mode),
])
return {
status: 'passed',
mode: options.mode,
account: {
id: account.id,
country: account.country ?? null,
defaultCurrency: account.default_currency ?? null,
chargesEnabled: account.charges_enabled === true,
payoutsEnabled: account.payouts_enabled === true,
detailsSubmitted: account.details_submitted === true,
},
prices: { PRO: pro, STUDIO: studio },
portal,
webhook,
webhookSigningSecretConfigured: options.webhookSecretConfigured,
webhookSigningSecretMatchedToEndpoint: false,
sideEffectsCreated: false,
}
}
function safeError(error) {
const message = error instanceof Error ? error.message : String(error)
return message.replace(/(?:sk|rk)_(?:test|live)_[a-zA-Z0-9]+|whsec_[a-zA-Z0-9]+/g, '[redacted]').slice(0, 1000)
}
async function main() {
const options = parseStripeSmokeOptions()
if (options.help) {
process.stdout.write(usage())
return
}
const report = await runStripeSmoke(options)
if (options.json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
return
}
process.stdout.write(`Stripe read-only smoke passed in ${report.mode} mode.\nAccount: ${report.account.id}\nWebhook: ${report.webhook.url}\nNo Stripe resources were created. Run a signed webhook acceptance event before launch.\n`)
}
if (process.argv[1] && resolve(process.argv[1]) === scriptPath) {
main().catch((error) => {
process.stderr.write(`Stripe smoke failed: ${safeError(error)}\n`)
process.exitCode = 1
})
}

98
scripts/verify-backup.sh Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
echo "Usage: $0 <frameflow-backup-directory>" >&2
exit 2
}
fail() {
echo "Backup verification failed: $1" >&2
exit 1
}
[[ $# -eq 1 ]] || usage
[[ -d "$1" ]] || fail "directory not found: $1"
[[ ! -L "$1" ]] || fail "backup directory must not be a symbolic link"
backup_dir="$(cd "$1" && pwd -P)"
required_files=(postgres.dump redis-data.tar.gz minio-data.tar.gz manifest.txt SHA256SUMS)
for filename in "${required_files[@]}"; do
path="${backup_dir}/${filename}"
[[ -f "${path}" ]] || fail "missing ${filename}"
[[ ! -L "${path}" ]] || fail "${filename} must not be a symbolic link"
[[ -s "${path}" ]] || fail "${filename} is empty"
done
created_at="$(sed -n 's/^created_at=//p' "${backup_dir}/manifest.txt")"
compose_project="$(sed -n 's/^compose_project=//p' "${backup_dir}/manifest.txt")"
[[ "${created_at}" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || fail "manifest has an invalid created_at value"
[[ "${compose_project}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || fail "manifest has an invalid compose_project value"
postgres_seen=0
redis_seen=0
minio_seen=0
checksum_rows=0
while read -r digest filename extra; do
[[ -z "${extra:-}" ]] || fail "SHA256SUMS contains an invalid row"
[[ "${digest}" =~ ^[[:xdigit:]]{64}$ ]] || fail "SHA256SUMS contains an invalid digest"
filename="${filename#\*}"
case "${filename}" in
postgres.dump) postgres_seen=$((postgres_seen + 1)) ;;
redis-data.tar.gz) redis_seen=$((redis_seen + 1)) ;;
minio-data.tar.gz) minio_seen=$((minio_seen + 1)) ;;
*) fail "SHA256SUMS references an unexpected file: ${filename}" ;;
esac
checksum_rows=$((checksum_rows + 1))
done < "${backup_dir}/SHA256SUMS"
[[ ${checksum_rows} -eq 3 && ${postgres_seen} -eq 1 && ${redis_seen} -eq 1 && ${minio_seen} -eq 1 ]] \
|| fail "SHA256SUMS must reference each data artifact exactly once"
if command -v sha256sum >/dev/null 2>&1; then
(cd "${backup_dir}" && sha256sum -c SHA256SUMS) >/dev/null \
|| fail "artifact checksum mismatch"
else
(cd "${backup_dir}" && shasum -a 256 -c SHA256SUMS) >/dev/null \
|| fail "artifact checksum mismatch"
fi
postgres_magic="$(LC_ALL=C head -c 5 "${backup_dir}/postgres.dump")"
[[ "${postgres_magic}" == "PGDMP" ]] || fail "PostgreSQL dump does not use the required custom format"
verify_archive_paths() {
local archive_name="$1"
local listing_file
local entry
local normalized
listing_file="$(mktemp "${TMPDIR:-/tmp}/frameflow-archive-list.XXXXXX")"
if ! tar -tzf "${backup_dir}/${archive_name}" > "${listing_file}"; then
rm -f "${listing_file}"
fail "${archive_name} is not a readable gzip tar archive"
fi
if [[ ! -s "${listing_file}" ]]; then
rm -f "${listing_file}"
fail "${archive_name} contains no entries"
fi
while IFS= read -r entry; do
[[ "${entry}" != /* ]] || {
rm -f "${listing_file}"
fail "${archive_name} contains an absolute path"
}
normalized="${entry#./}"
case "/${normalized}/" in
*"/../"*)
rm -f "${listing_file}"
fail "${archive_name} contains a parent-directory path"
;;
esac
done < "${listing_file}"
rm -f "${listing_file}"
}
verify_archive_paths redis-data.tar.gz
verify_archive_paths minio-data.tar.gz
echo "Backup verification passed: ${backup_dir}"
echo "Source Compose project: ${compose_project}; created at: ${created_at}"

12
server/drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow',
},
strict: true,
verbose: true,
})

View File

@@ -0,0 +1,356 @@
CREATE TYPE "public"."asset_status" AS ENUM('DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'ARCHIVED');--> statement-breakpoint
CREATE TYPE "public"."asset_type" AS ENUM('CHARACTER', 'SCENE', 'PROP', 'VOICE', 'MUSIC');--> statement-breakpoint
CREATE TYPE "public"."delivery_status" AS ENUM('DRAFT', 'QUEUED', 'PUBLISHING', 'PUBLISHED', 'FAILED');--> statement-breakpoint
CREATE TYPE "public"."episode_status" AS ENUM('OUTLINE', 'SCRIPTING', 'STORYBOARDING', 'PRODUCTION', 'REVIEW', 'PUBLISHED');--> statement-breakpoint
CREATE TYPE "public"."job_status" AS ENUM('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');--> statement-breakpoint
CREATE TYPE "public"."job_type" AS ENUM('SCRIPT_GENERATE', 'ASSET_GENERATE', 'TTS_GENERATE', 'VIDEO_GENERATE', 'LIPSYNC_GENERATE', 'EPISODE_RENDER', 'DELIVERY_PUBLISH');--> statement-breakpoint
CREATE TYPE "public"."membership_role" AS ENUM('OWNER', 'ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER');--> statement-breakpoint
CREATE TYPE "public"."project_status" AS ENUM('DRAFT', 'ACTIVE', 'ARCHIVED');--> statement-breakpoint
CREATE TYPE "public"."render_status" AS ENUM('QUEUED', 'RENDERING', 'REVIEW', 'APPROVED', 'FAILED');--> statement-breakpoint
CREATE TYPE "public"."review_status" AS ENUM('OPEN', 'APPROVED', 'CHANGES_REQUESTED', 'CANCELLED');--> statement-breakpoint
CREATE TYPE "public"."review_target_type" AS ENUM('SCRIPT_VERSION', 'ASSET_VERSION', 'SHOT_VERSION', 'RENDER');--> statement-breakpoint
CREATE TYPE "public"."shot_status" AS ENUM('DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'RENDERED');--> statement-breakpoint
CREATE TYPE "public"."usage_kind" AS ENUM('TEXT_INPUT_TOKEN', 'TEXT_OUTPUT_TOKEN', 'IMAGE', 'AUDIO_SECOND', 'VIDEO_SECOND', 'RENDER_SECOND', 'STORAGE_BYTE_MONTH');--> statement-breakpoint
CREATE TABLE "asset_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"asset_id" uuid NOT NULL,
"version" integer NOT NULL,
"storage_key" text,
"thumbnail_key" text,
"mime_type" varchar(120),
"byte_size" integer,
"width" integer,
"height" integer,
"duration_ms" integer,
"prompt" text DEFAULT '' NOT NULL,
"negative_prompt" text DEFAULT '' NOT NULL,
"provider" varchar(80),
"provider_model" varchar(160),
"seed" varchar(80),
"generation_params" jsonb DEFAULT '{}'::jsonb NOT NULL,
"is_current" boolean DEFAULT false NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "assets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"type" "asset_type" NOT NULL,
"name" varchar(180) NOT NULL,
"description" text DEFAULT '' NOT NULL,
"status" "asset_status" DEFAULT 'DRAFT' NOT NULL,
"tags" jsonb DEFAULT '[]'::jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audit_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid,
"actor_id" uuid,
"action" varchar(120) NOT NULL,
"resource_type" varchar(80) NOT NULL,
"resource_id" uuid,
"ip_address" varchar(64),
"user_agent" text,
"before" jsonb,
"after" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "deliveries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"render_id" uuid NOT NULL,
"platform" varchar(80) NOT NULL,
"status" "delivery_status" DEFAULT 'DRAFT' NOT NULL,
"destination" jsonb DEFAULT '{}'::jsonb NOT NULL,
"external_id" varchar(255),
"published_url" text,
"scheduled_at" timestamp with time zone,
"published_at" timestamp with time zone,
"created_by_id" uuid NOT NULL,
"error_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "episodes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"episode_number" integer NOT NULL,
"title" varchar(200) NOT NULL,
"synopsis" text DEFAULT '' NOT NULL,
"target_duration_seconds" integer DEFAULT 180 NOT NULL,
"status" "episode_status" DEFAULT 'OUTLINE' NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "generation_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"project_id" uuid,
"episode_id" uuid,
"created_by_id" uuid NOT NULL,
"type" "job_type" NOT NULL,
"status" "job_status" DEFAULT 'QUEUED' NOT NULL,
"progress" integer DEFAULT 0 NOT NULL,
"provider" varchar(80),
"provider_model" varchar(160),
"external_id" varchar(255),
"idempotency_key" varchar(180) NOT NULL,
"input" jsonb NOT NULL,
"output" jsonb,
"error_code" varchar(100),
"error_message" text,
"attempts" integer DEFAULT 0 NOT NULL,
"max_attempts" integer DEFAULT 3 NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberships" (
"workspace_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" "membership_role" DEFAULT 'VIEWER' NOT NULL,
"invited_by_id" uuid,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "memberships_workspace_id_user_id_pk" PRIMARY KEY("workspace_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"created_by_id" uuid NOT NULL,
"name" varchar(180) NOT NULL,
"slug" varchar(100) NOT NULL,
"logline" text DEFAULT '' NOT NULL,
"genre" varchar(80) DEFAULT '都市悬疑' NOT NULL,
"visual_bible" jsonb DEFAULT '{}'::jsonb NOT NULL,
"status" "project_status" DEFAULT 'DRAFT' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "refresh_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
"replaced_by_id" uuid,
"user_agent" text,
"ip_address" varchar(64),
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "renders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"episode_id" uuid NOT NULL,
"version" integer NOT NULL,
"status" "render_status" DEFAULT 'QUEUED' NOT NULL,
"storage_key" text,
"thumbnail_key" text,
"mime_type" varchar(120) DEFAULT 'video/mp4' NOT NULL,
"byte_size" integer,
"duration_ms" integer,
"width" integer NOT NULL,
"height" integer NOT NULL,
"fps" integer DEFAULT 24 NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"quality_report" jsonb,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "review_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"review_request_id" uuid NOT NULL,
"author_id" uuid NOT NULL,
"body" text NOT NULL,
"timecode_ms" integer,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "review_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"target_type" "review_target_type" NOT NULL,
"target_id" uuid NOT NULL,
"status" "review_status" DEFAULT 'OPEN' NOT NULL,
"requested_by_id" uuid NOT NULL,
"assigned_to_id" uuid,
"resolved_by_id" uuid,
"summary" text DEFAULT '' NOT NULL,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "script_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"episode_id" uuid NOT NULL,
"version" integer NOT NULL,
"title" varchar(200) NOT NULL,
"content" jsonb NOT NULL,
"plain_text" text DEFAULT '' NOT NULL,
"change_summary" text DEFAULT '' NOT NULL,
"is_current" boolean DEFAULT false NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shot_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"shot_id" uuid NOT NULL,
"version" integer NOT NULL,
"image_asset_version_id" uuid,
"video_storage_key" text,
"audio_storage_key" text,
"subtitle_text" text DEFAULT '' NOT NULL,
"generation_params" jsonb DEFAULT '{}'::jsonb NOT NULL,
"is_current" boolean DEFAULT false NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "shots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"episode_id" uuid NOT NULL,
"shot_number" integer NOT NULL,
"scene_number" integer NOT NULL,
"title" varchar(180) NOT NULL,
"description" text DEFAULT '' NOT NULL,
"shot_type" varchar(50) DEFAULT '中景' NOT NULL,
"camera_motion" varchar(50) DEFAULT '静止' NOT NULL,
"duration_ms" integer DEFAULT 3000 NOT NULL,
"status" "shot_status" DEFAULT 'DRAFT' NOT NULL,
"character_asset_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
"scene_asset_id" uuid,
"sort_order" integer NOT NULL,
"created_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "usage_ledger" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"user_id" uuid,
"job_id" uuid,
"kind" "usage_kind" NOT NULL,
"quantity" numeric(20, 4) NOT NULL,
"credits" numeric(20, 4) NOT NULL,
"provider_cost_usd" numeric(20, 6),
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(320) NOT NULL,
"display_name" varchar(120) NOT NULL,
"password_hash" text NOT NULL,
"avatar_key" text,
"email_verified_at" timestamp with time zone,
"disabled_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(160) NOT NULL,
"slug" varchar(80) NOT NULL,
"owner_id" uuid NOT NULL,
"plan" varchar(32) DEFAULT 'PRO' NOT NULL,
"monthly_credit_limit" integer DEFAULT 10000 NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "asset_versions" ADD CONSTRAINT "asset_versions_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "asset_versions" ADD CONSTRAINT "asset_versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "assets" ADD CONSTRAINT "assets_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_render_id_renders_id_fk" FOREIGN KEY ("render_id") REFERENCES "public"."renders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "episodes" ADD CONSTRAINT "episodes_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "episodes" ADD CONSTRAINT "episodes_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_invited_by_id_users_id_fk" FOREIGN KEY ("invited_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "renders" ADD CONSTRAINT "renders_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "renders" ADD CONSTRAINT "renders_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_comments" ADD CONSTRAINT "review_comments_review_request_id_review_requests_id_fk" FOREIGN KEY ("review_request_id") REFERENCES "public"."review_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_comments" ADD CONSTRAINT "review_comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_requested_by_id_users_id_fk" FOREIGN KEY ("requested_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_assigned_to_id_users_id_fk" FOREIGN KEY ("assigned_to_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_resolved_by_id_users_id_fk" FOREIGN KEY ("resolved_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "script_versions" ADD CONSTRAINT "script_versions_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "script_versions" ADD CONSTRAINT "script_versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shot_versions" ADD CONSTRAINT "shot_versions_shot_id_shots_id_fk" FOREIGN KEY ("shot_id") REFERENCES "public"."shots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shot_versions" ADD CONSTRAINT "shot_versions_image_asset_version_id_asset_versions_id_fk" FOREIGN KEY ("image_asset_version_id") REFERENCES "public"."asset_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shot_versions" ADD CONSTRAINT "shot_versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shots" ADD CONSTRAINT "shots_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shots" ADD CONSTRAINT "shots_scene_asset_id_assets_id_fk" FOREIGN KEY ("scene_asset_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shots" ADD CONSTRAINT "shots_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_job_id_generation_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."generation_jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "asset_versions_asset_version_unique" ON "asset_versions" USING btree ("asset_id","version");--> statement-breakpoint
CREATE INDEX "asset_versions_current_idx" ON "asset_versions" USING btree ("asset_id","is_current");--> statement-breakpoint
CREATE INDEX "assets_project_type_idx" ON "assets" USING btree ("project_id","type");--> statement-breakpoint
CREATE INDEX "assets_project_status_idx" ON "assets" USING btree ("project_id","status");--> statement-breakpoint
CREATE INDEX "audit_logs_workspace_time_idx" ON "audit_logs" USING btree ("workspace_id","created_at");--> statement-breakpoint
CREATE INDEX "audit_logs_actor_idx" ON "audit_logs" USING btree ("actor_id");--> statement-breakpoint
CREATE INDEX "deliveries_render_idx" ON "deliveries" USING btree ("render_id");--> statement-breakpoint
CREATE INDEX "deliveries_status_idx" ON "deliveries" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "episodes_project_number_unique" ON "episodes" USING btree ("project_id","episode_number");--> statement-breakpoint
CREATE INDEX "episodes_project_idx" ON "episodes" USING btree ("project_id");--> statement-breakpoint
CREATE UNIQUE INDEX "generation_jobs_idempotency_unique" ON "generation_jobs" USING btree ("workspace_id","idempotency_key");--> statement-breakpoint
CREATE INDEX "generation_jobs_workspace_status_idx" ON "generation_jobs" USING btree ("workspace_id","status");--> statement-breakpoint
CREATE INDEX "generation_jobs_project_idx" ON "generation_jobs" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "memberships_user_idx" ON "memberships" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "projects_workspace_slug_unique" ON "projects" USING btree ("workspace_id","slug");--> statement-breakpoint
CREATE INDEX "projects_workspace_idx" ON "projects" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "refresh_tokens_hash_unique" ON "refresh_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "refresh_tokens_user_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "renders_episode_version_unique" ON "renders" USING btree ("episode_id","version");--> statement-breakpoint
CREATE INDEX "renders_episode_status_idx" ON "renders" USING btree ("episode_id","status");--> statement-breakpoint
CREATE INDEX "review_comments_request_idx" ON "review_comments" USING btree ("review_request_id");--> statement-breakpoint
CREATE INDEX "review_requests_workspace_status_idx" ON "review_requests" USING btree ("workspace_id","status");--> statement-breakpoint
CREATE INDEX "review_requests_target_idx" ON "review_requests" USING btree ("target_type","target_id");--> statement-breakpoint
CREATE UNIQUE INDEX "script_versions_episode_version_unique" ON "script_versions" USING btree ("episode_id","version");--> statement-breakpoint
CREATE INDEX "script_versions_episode_current_idx" ON "script_versions" USING btree ("episode_id","is_current");--> statement-breakpoint
CREATE UNIQUE INDEX "shot_versions_shot_version_unique" ON "shot_versions" USING btree ("shot_id","version");--> statement-breakpoint
CREATE INDEX "shot_versions_current_idx" ON "shot_versions" USING btree ("shot_id","is_current");--> statement-breakpoint
CREATE UNIQUE INDEX "shots_episode_number_unique" ON "shots" USING btree ("episode_id","shot_number");--> statement-breakpoint
CREATE INDEX "shots_episode_sort_idx" ON "shots" USING btree ("episode_id","sort_order");--> statement-breakpoint
CREATE INDEX "usage_ledger_workspace_time_idx" ON "usage_ledger" USING btree ("workspace_id","occurred_at");--> statement-breakpoint
CREATE INDEX "usage_ledger_job_idx" ON "usage_ledger" USING btree ("job_id");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "workspaces_slug_unique" ON "workspaces" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "workspaces_owner_idx" ON "workspaces" USING btree ("owner_id");

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."job_type" ADD VALUE 'PIPELINE_HEALTHCHECK' BEFORE 'SCRIPT_GENERATE';

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."asset_type" ADD VALUE 'SHOT';

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."job_type" ADD VALUE 'STORYBOARD_GENERATE' BEFORE 'ASSET_GENERATE';

View File

@@ -0,0 +1 @@
ALTER TABLE "generation_jobs" ADD COLUMN "estimated_credits" numeric(20, 4) DEFAULT '0' NOT NULL;

View File

@@ -0,0 +1,21 @@
CREATE TABLE "workspace_invitations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"email" varchar(320) NOT NULL,
"role" "membership_role" DEFAULT 'VIEWER' NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"accepted_at" timestamp with time zone,
"accepted_by_id" uuid,
"revoked_at" timestamp with time zone,
"invited_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_accepted_by_id_users_id_fk" FOREIGN KEY ("accepted_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_invited_by_id_users_id_fk" FOREIGN KEY ("invited_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "workspace_invitations_token_hash_unique" ON "workspace_invitations" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "workspace_invitations_workspace_email_idx" ON "workspace_invitations" USING btree ("workspace_id","email");--> statement-breakpoint
CREATE INDEX "workspace_invitations_workspace_created_idx" ON "workspace_invitations" USING btree ("workspace_id","created_at");

View File

@@ -0,0 +1,24 @@
CREATE TYPE "public"."production_run_status" AS ENUM('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');--> statement-breakpoint
CREATE TABLE "production_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"episode_id" uuid NOT NULL,
"render_id" uuid NOT NULL,
"status" "production_run_status" DEFAULT 'QUEUED' NOT NULL,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"total_jobs" integer NOT NULL,
"created_by_id" uuid NOT NULL,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "generation_jobs" ADD COLUMN "production_run_id" uuid;--> statement-breakpoint
ALTER TABLE "production_runs" ADD CONSTRAINT "production_runs_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "production_runs" ADD CONSTRAINT "production_runs_render_id_renders_id_fk" FOREIGN KEY ("render_id") REFERENCES "public"."renders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "production_runs" ADD CONSTRAINT "production_runs_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "production_runs_render_unique" ON "production_runs" USING btree ("render_id");--> statement-breakpoint
CREATE INDEX "production_runs_episode_status_idx" ON "production_runs" USING btree ("episode_id","status");--> statement-breakpoint
CREATE INDEX "production_runs_episode_created_idx" ON "production_runs" USING btree ("episode_id","created_at");--> statement-breakpoint
ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_production_run_id_production_runs_id_fk" FOREIGN KEY ("production_run_id") REFERENCES "public"."production_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "generation_jobs_production_run_idx" ON "generation_jobs" USING btree ("production_run_id");

View File

@@ -0,0 +1,15 @@
CREATE TYPE "public"."account_token_type" AS ENUM('EMAIL_VERIFICATION', 'PASSWORD_RESET');--> statement-breakpoint
CREATE TABLE "account_action_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" "account_token_type" NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"consumed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "account_action_tokens" ADD CONSTRAINT "account_action_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "account_action_tokens_hash_unique" ON "account_action_tokens" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "account_action_tokens_user_type_idx" ON "account_action_tokens" USING btree ("user_id","type");--> statement-breakpoint
CREATE INDEX "account_action_tokens_expiry_idx" ON "account_action_tokens" USING btree ("expires_at");

View File

@@ -0,0 +1,4 @@
ALTER TYPE "public"."delivery_status" ADD VALUE 'CANCELLED';--> statement-breakpoint
ALTER TABLE "deliveries" ADD COLUMN "job_id" uuid;--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_job_id_generation_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."generation_jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "deliveries_job_idx" ON "deliveries" USING btree ("job_id");

View File

@@ -0,0 +1,34 @@
CREATE TABLE "billing_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"provider" varchar(32) DEFAULT 'stripe' NOT NULL,
"customer_id" varchar(255) NOT NULL,
"subscription_id" varchar(255),
"price_id" varchar(255),
"plan" varchar(32),
"status" varchar(48) DEFAULT 'no_subscription' NOT NULL,
"current_period_end" timestamp with time zone,
"cancel_at_period_end" boolean DEFAULT false NOT NULL,
"last_event_created_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "billing_webhook_events" (
"event_id" varchar(255) PRIMARY KEY NOT NULL,
"provider" varchar(32) DEFAULT 'stripe' NOT NULL,
"event_type" varchar(160) NOT NULL,
"livemode" boolean DEFAULT false NOT NULL,
"workspace_id" uuid,
"provider_created_at" timestamp with time zone NOT NULL,
"processed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "billing_webhook_events" ADD CONSTRAINT "billing_webhook_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "billing_subscriptions_workspace_unique" ON "billing_subscriptions" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "billing_subscriptions_customer_unique" ON "billing_subscriptions" USING btree ("customer_id");--> statement-breakpoint
CREATE UNIQUE INDEX "billing_subscriptions_subscription_unique" ON "billing_subscriptions" USING btree ("subscription_id");--> statement-breakpoint
CREATE INDEX "billing_subscriptions_status_idx" ON "billing_subscriptions" USING btree ("status");--> statement-breakpoint
CREATE INDEX "billing_webhook_events_workspace_idx" ON "billing_webhook_events" USING btree ("workspace_id","processed_at");--> statement-breakpoint
CREATE INDEX "billing_webhook_events_type_idx" ON "billing_webhook_events" USING btree ("event_type");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "renders" ADD COLUMN "subtitle_srt_key" text;--> statement-breakpoint
ALTER TABLE "renders" ADD COLUMN "subtitle_vtt_key" text;--> statement-breakpoint
ALTER TABLE "renders" ADD COLUMN "source_manifest" jsonb;

View File

@@ -0,0 +1,25 @@
CREATE TYPE "public"."notification_tone" AS ENUM('INFO', 'SUCCESS', 'WARNING', 'ERROR');--> statement-breakpoint
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"kind" varchar(80) NOT NULL,
"tone" "notification_tone" DEFAULT 'INFO' NOT NULL,
"title" varchar(240) NOT NULL,
"body" text DEFAULT '' NOT NULL,
"page" varchar(48) NOT NULL,
"resource_type" varchar(80),
"resource_id" uuid,
"dedupe_key" varchar(255) NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"read_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "notifications_user_dedupe_unique" ON "notifications" USING btree ("user_id","dedupe_key");--> statement-breakpoint
CREATE INDEX "notifications_user_created_idx" ON "notifications" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "notifications_user_unread_idx" ON "notifications" USING btree ("user_id","read_at","created_at");--> statement-breakpoint
CREATE INDEX "notifications_workspace_created_idx" ON "notifications" USING btree ("workspace_id","created_at");

View File

@@ -0,0 +1,62 @@
CREATE TYPE "public"."publishing_channel_status" AS ENUM('CONNECTED', 'EXPIRED', 'REVOKED', 'ERROR');--> statement-breakpoint
CREATE TABLE "publishing_channel_accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"platform" varchar(32) NOT NULL,
"external_account_id" varchar(255) NOT NULL,
"display_name" varchar(180) NOT NULL,
"avatar_url" text,
"status" "publishing_channel_status" DEFAULT 'CONNECTED' NOT NULL,
"access_token_ciphertext" text,
"refresh_token_ciphertext" text,
"token_expires_at" timestamp with time zone,
"scopes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"connected_by_id" uuid NOT NULL,
"last_verified_at" timestamp with time zone DEFAULT now() NOT NULL,
"error_code" varchar(100),
"error_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "publishing_oauth_states" (
"state_hash" varchar(64) PRIMARY KEY NOT NULL,
"workspace_id" uuid NOT NULL,
"platform" varchar(32) NOT NULL,
"code_verifier_ciphertext" text,
"return_path" text DEFAULT '/?publishing=connected' NOT NULL,
"created_by_id" uuid NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"consumed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "publishing_webhook_events" (
"event_key" varchar(320) PRIMARY KEY NOT NULL,
"event_id" varchar(255) NOT NULL,
"platform" varchar(32) NOT NULL,
"delivery_id" uuid,
"status" varchar(32) NOT NULL,
"provider_occurred_at" timestamp with time zone NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"applied" boolean DEFAULT false NOT NULL,
"processed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "deliveries" ADD COLUMN "channel_account_id" uuid;--> statement-breakpoint
ALTER TABLE "deliveries" ADD COLUMN "last_provider_event_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "publishing_channel_accounts" ADD CONSTRAINT "publishing_channel_accounts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "publishing_channel_accounts" ADD CONSTRAINT "publishing_channel_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "publishing_oauth_states" ADD CONSTRAINT "publishing_oauth_states_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "publishing_oauth_states" ADD CONSTRAINT "publishing_oauth_states_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "publishing_webhook_events" ADD CONSTRAINT "publishing_webhook_events_delivery_id_deliveries_id_fk" FOREIGN KEY ("delivery_id") REFERENCES "public"."deliveries"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "publishing_channel_accounts_external_unique" ON "publishing_channel_accounts" USING btree ("workspace_id","platform","external_account_id");--> statement-breakpoint
CREATE INDEX "publishing_channel_accounts_workspace_idx" ON "publishing_channel_accounts" USING btree ("workspace_id","platform");--> statement-breakpoint
CREATE INDEX "publishing_channel_accounts_status_idx" ON "publishing_channel_accounts" USING btree ("workspace_id","status");--> statement-breakpoint
CREATE INDEX "publishing_oauth_states_expiry_idx" ON "publishing_oauth_states" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "publishing_oauth_states_workspace_idx" ON "publishing_oauth_states" USING btree ("workspace_id","platform");--> statement-breakpoint
CREATE INDEX "publishing_webhook_events_delivery_idx" ON "publishing_webhook_events" USING btree ("delivery_id","provider_occurred_at");--> statement-breakpoint
CREATE INDEX "publishing_webhook_events_platform_idx" ON "publishing_webhook_events" USING btree ("platform","processed_at");--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk" FOREIGN KEY ("channel_account_id") REFERENCES "public"."publishing_channel_accounts"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "deliveries_channel_account_idx" ON "deliveries" USING btree ("channel_account_id");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "publishing_webhook_events" ADD COLUMN "workspace_id" uuid NOT NULL;--> statement-breakpoint
ALTER TABLE "publishing_webhook_events" ADD CONSTRAINT "publishing_webhook_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "publishing_webhook_events_workspace_idx" ON "publishing_webhook_events" USING btree ("workspace_id","processed_at");

View File

@@ -0,0 +1,3 @@
ALTER TABLE "deliveries" DROP CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk";
--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk" FOREIGN KEY ("channel_account_id") REFERENCES "public"."publishing_channel_accounts"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "deliveries" DROP CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk";
--> statement-breakpoint
ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk" FOREIGN KEY ("channel_account_id") REFERENCES "public"."publishing_channel_accounts"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,20 @@
CREATE TABLE "provider_verifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"provider" varchar(32) NOT NULL,
"status" varchar(32) NOT NULL,
"configuration_fingerprint" varchar(64) NOT NULL,
"checked_at" timestamp with time zone DEFAULT now() NOT NULL,
"latency_ms" integer,
"error_code" varchar(80),
"message" text,
"details" jsonb DEFAULT '{}'::jsonb NOT NULL,
"checked_by_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "provider_verifications" ADD CONSTRAINT "provider_verifications_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "provider_verifications" ADD CONSTRAINT "provider_verifications_checked_by_id_users_id_fk" FOREIGN KEY ("checked_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "provider_verifications_workspace_provider_unique" ON "provider_verifications" USING btree ("workspace_id","provider");--> statement-breakpoint
CREATE INDEX "provider_verifications_workspace_status_idx" ON "provider_verifications" USING btree ("workspace_id","status");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1785398872080,
"tag": "0000_last_spectrum",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785400342760,
"tag": "0001_perfect_gravity",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785401841259,
"tag": "0002_yielding_spencer_smythe",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785404103710,
"tag": "0003_hard_norrin_radd",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785405889215,
"tag": "0004_goofy_karnak",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785431751467,
"tag": "0005_brave_betty_ross",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1785432812502,
"tag": "0006_wooden_texas_twister",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1785434191972,
"tag": "0007_sudden_roland_deschain",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1785435268450,
"tag": "0008_red_trish_tilby",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1785437272781,
"tag": "0009_chilly_mockingbird",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1785439338694,
"tag": "0010_sharp_odin",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785446641562,
"tag": "0011_futuristic_stature",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1785450378611,
"tag": "0012_warm_old_lace",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1785450438724,
"tag": "0013_lethal_giant_man",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1785450621103,
"tag": "0014_heavy_retro_girl",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1785450684178,
"tag": "0015_reflective_true_believers",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1785456376158,
"tag": "0016_amused_longshot",
"breakpoints": true
}
]
}

52
server/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "@frameflow/server",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"worker": "tsx watch src/worker.ts",
"start": "node dist/index.js",
"start:worker": "node dist/worker.js",
"config:check": "tsx src/config-check.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.2.1",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"argon2": "^0.44.0",
"bullmq": "^5.58.0",
"dotenv": "^17.2.1",
"drizzle-orm": "^0.45.2",
"fastify": "^5.5.0",
"fastify-raw-body": "^5.0.0",
"ioredis": "^5.7.0",
"ipaddr.js": "^2.4.0",
"openai": "^7.2.0",
"pino": "^10.3.1",
"postgres": "^3.4.7",
"prom-client": "^15.1.3",
"stripe": "^22.4.0",
"zod": "^4.0.17"
},
"devDependencies": {
"@types/node": "^24.2.0",
"drizzle-kit": "^0.31.5",
"tsx": "^4.20.4",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}

168
server/src/app.ts Normal file
View File

@@ -0,0 +1,168 @@
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import jwt from '@fastify/jwt'
import multipart from '@fastify/multipart'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import rawBody from 'fastify-raw-body'
import Fastify from 'fastify'
import { sql } from 'drizzle-orm'
import { ZodError } from 'zod'
import { config } from './config.js'
import { db } from './db/client.js'
import { AppError } from './lib/errors.js'
import { apiRequestDurationSeconds, apiRequestsTotal, applicationDependencyAvailable, metricsRegistry } from './lib/metrics.js'
import { authRoutes } from './routes/auth.js'
import { assetRoutes } from './routes/assets.js'
import { episodeRoutes } from './routes/episodes.js'
import { projectRoutes } from './routes/projects.js'
import { shotRoutes } from './routes/shots.js'
import { workspaceRoutes } from './routes/workspaces.js'
import { jobRoutes } from './routes/jobs.js'
import { uploadRoutes } from './routes/uploads.js'
import { productionRoutes } from './routes/production.js'
import { reviewRoutes } from './routes/reviews.js'
import { billingRoutes } from './routes/billing.js'
import { readinessRoutes } from './routes/readiness.js'
import { auditRoutes } from './routes/audit.js'
import { notificationRoutes } from './routes/notifications.js'
import { publishingRoutes } from './routes/publishing.js'
import type { StripeBillingProvider } from './lib/stripe-billing.js'
import type { PublishingAdapterRegistry } from './publishing/types.js'
import type { InvitationMailer } from './lib/mailer.js'
import type { ProviderConfiguration } from './providers/readiness.js'
import { checkRedis, redis } from './queue/connection.js'
import { closeQueue } from './queue/client.js'
import { checkWorkerHeartbeat } from './queue/heartbeat.js'
import { checkStorage, closeStorage } from './storage/client.js'
export interface BuildAppOptions {
billingProvider?: StripeBillingProvider
invitationMailer?: InvitationMailer
publishingAdapters?: PublishingAdapterRegistry
publishingWebhookSecret?: string
providerVerificationFetch?: typeof fetch
providerVerificationConfiguration?: ProviderConfiguration
}
export async function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: { level: config.LOG_LEVEL },
requestIdHeader: 'x-request-id',
trustProxy: config.NODE_ENV === 'production',
bodyLimit: config.MAX_UPLOAD_BYTES,
})
await app.register(helmet, { contentSecurityPolicy: config.NODE_ENV === 'production' })
await app.register(cors, {
origin: config.WEB_ORIGIN.split(',').map((origin) => origin.trim()),
credentials: true,
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
})
await app.register(cookie)
await app.register(sensible)
await app.register(rawBody, { global: false, encoding: false, runFirst: true })
await app.register(rateLimit, { max: 120, timeWindow: '1 minute' })
await app.register(multipart, {
limits: { fileSize: config.MAX_UPLOAD_BYTES, files: 8 },
})
await app.register(jwt, { secret: config.JWT_ACCESS_SECRET })
app.get('/health/live', async () => ({ status: 'ok', service: 'frameflow-api' }))
app.get('/health/ready', async (_request, reply) => {
const checks = await Promise.allSettled([
db.execute(sql`select 1`),
checkRedis(),
checkStorage(),
checkWorkerHeartbeat(),
])
const names = ['postgres', 'redis', 'objectStorage', 'worker'] as const
const dependencies = Object.fromEntries(names.map((name, index) => [name, checks[index]?.status === 'fulfilled' ? 'ok' : 'unavailable']))
names.forEach((name, index) => applicationDependencyAvailable.set({ dependency: name }, checks[index]?.status === 'fulfilled' ? 1 : 0))
const ready = checks.every((check) => check.status === 'fulfilled')
return reply.code(ready ? 200 : 503).send({ status: ready ? 'ready' : 'not_ready', dependencies })
})
app.get('/metrics', async (request, reply) => {
if (config.METRICS_TOKEN && request.headers.authorization !== `Bearer ${config.METRICS_TOKEN}`) {
return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Metrics token is missing or invalid' } })
}
return reply.header('content-type', metricsRegistry.contentType).send(await metricsRegistry.metrics())
})
app.addHook('onResponse', async (request, reply) => {
const labels = {
method: request.method,
route: request.routeOptions.url ?? 'unmatched',
status: String(reply.statusCode),
}
apiRequestsTotal.inc(labels)
apiRequestDurationSeconds.observe(labels, reply.elapsedTime / 1000)
})
app.addHook('onClose', async () => {
await Promise.allSettled([closeQueue(), redis.quit()])
closeStorage()
})
app.setNotFoundHandler((request, reply) => {
reply.code(404).send({ error: { code: 'NOT_FOUND', message: `Route ${request.method} ${request.url} not found` } })
})
app.setErrorHandler((error, request, reply) => {
const appError = error as AppError
const businessCodes = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'EMAIL_VERIFICATION_REQUIRED', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'CREDIT_LIMIT_EXCEEDED', 'SERVICE_UNAVAILABLE'])
if (error instanceof AppError || appError.name === 'AppError' || businessCodes.has(appError.code)) {
return reply.code(appError.statusCode).send({
error: { code: appError.code, message: appError.message, details: appError.details },
requestId: request.id,
})
}
if (error instanceof ZodError) {
return reply.code(400).send({
error: { code: 'BAD_REQUEST', message: 'Request validation failed', details: error.issues },
requestId: request.id,
})
}
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
return reply.code(409).send({ error: { code: 'CONFLICT', message: 'A unique value already exists' }, requestId: request.id })
}
request.log.error({ err: error }, 'Unhandled request error')
return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Unexpected server error' }, requestId: request.id })
})
await app.register(authRoutes, { prefix: '/api/v1/auth' })
await app.register(workspaceRoutes, {
prefix: '/api/v1/workspaces',
...(options.invitationMailer ? { invitationMailer: options.invitationMailer } : {}),
})
await app.register(projectRoutes, { prefix: '/api/v1' })
await app.register(episodeRoutes, { prefix: '/api/v1' })
await app.register(assetRoutes, { prefix: '/api/v1' })
await app.register(shotRoutes, { prefix: '/api/v1' })
await app.register(uploadRoutes, { prefix: '/api/v1' })
await app.register(jobRoutes, { prefix: '/api/v1' })
await app.register(productionRoutes, {
prefix: '/api/v1',
...(options.publishingAdapters ? { publishingAdapters: options.publishingAdapters } : {}),
})
await app.register(reviewRoutes, { prefix: '/api/v1' })
await app.register(billingRoutes, {
prefix: '/api/v1',
...(options.billingProvider ? { billingProvider: options.billingProvider } : {}),
})
await app.register(readinessRoutes, {
prefix: '/api/v1',
...(options.providerVerificationFetch ? { providerVerificationFetch: options.providerVerificationFetch } : {}),
...(options.providerVerificationConfiguration ? { providerVerificationConfiguration: options.providerVerificationConfiguration } : {}),
})
await app.register(auditRoutes, { prefix: '/api/v1' })
await app.register(notificationRoutes, { prefix: '/api/v1' })
await app.register(publishingRoutes, {
prefix: '/api/v1',
...(options.publishingAdapters ? { publishingAdapters: options.publishingAdapters } : {}),
...(options.publishingWebhookSecret ? { publishingWebhookSecret: options.publishingWebhookSecret } : {}),
})
return app
}

View File

@@ -0,0 +1,22 @@
import { config } from './config.js'
const replicateCapabilities = [
config.REPLICATE_IMAGE_VERSION && 'image',
config.REPLICATE_VIDEO_VERSION && 'video',
config.REPLICATE_LIPSYNC_VERSION && 'lipsync',
].filter(Boolean)
console.log(JSON.stringify({
status: 'valid',
environment: config.NODE_ENV,
emailDelivery: config.RESEND_API_KEY ? 'configured' : 'disabled',
openai: config.OPENAI_API_KEY ? 'configured' : 'disabled',
agnes: config.AGNES_API_KEY ? ['text', 'image', 'video'] : [],
generic: [
config.GENERIC_TEXT_API_KEY && config.GENERIC_TEXT_BASE_URL && config.GENERIC_TEXT_MODEL && 'text',
config.GENERIC_IMAGE_API_KEY && config.GENERIC_IMAGE_BASE_URL && config.GENERIC_IMAGE_MODEL && 'image',
config.GENERIC_VIDEO_API_KEY && config.GENERIC_VIDEO_BASE_URL && config.GENERIC_VIDEO_MODEL && 'video',
].filter(Boolean),
replicate: config.REPLICATE_API_TOKEN ? replicateCapabilities : [],
stripe: config.STRIPE_SECRET_KEY ? 'configured' : 'disabled',
}))

113
server/src/config.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import { parseConfig } from './config.js'
function productionEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
return {
NODE_ENV: 'production',
WEB_ORIGIN: 'https://studio.frameflow.cn',
API_PUBLIC_ORIGIN: 'https://studio.frameflow.cn',
DATABASE_URL: 'postgresql://frameflow:postgres-password-32-characters@postgres:5432/frameflow',
REDIS_URL: 'redis://default:redis-password-32-characters@redis:6379',
JWT_ACCESS_SECRET: 'access-secret-with-at-least-32-characters',
JWT_REFRESH_SECRET: 'refresh-secret-with-at-least-32-characters',
CREDENTIAL_ENCRYPTION_KEY: 'credential-encryption-secret-with-32-characters',
EMAIL_VERIFICATION_REQUIRED: 'true',
RESEND_API_KEY: 're_live_frameflow_configuration',
EMAIL_FROM: 'FrameFlow <noreply@frameflow.cn>',
S3_ENDPOINT: 'http://minio:9000',
S3_PUBLIC_ENDPOINT: 'https://media.frameflow.cn',
S3_ACCESS_KEY: 'frameflow-production-access',
S3_SECRET_KEY: 'storage-secret-with-at-least-32-characters',
...overrides,
}
}
describe('production environment validation', () => {
it('accepts a complete production configuration with optional providers disabled', () => {
const result = parseConfig(productionEnvironment())
expect(result.NODE_ENV).toBe('production')
expect(result.EMAIL_VERIFICATION_REQUIRED).toBe(true)
expect(result.S3_PUBLIC_ENDPOINT).toBe('https://media.frameflow.cn')
})
it('rejects placeholder secrets without exposing their values', () => {
expect(() => parseConfig(productionEnvironment({
JWT_ACCESS_SECRET: 'replace_with_at_least_32_random_characters_access',
}))).toThrow('JWT_ACCESS_SECRET contains a placeholder value')
})
it('requires real HTTPS public origins', () => {
expect(() => parseConfig(productionEnvironment({
WEB_ORIGIN: 'http://studio.example.com',
S3_PUBLIC_ENDPOINT: 'http://127.0.0.1:9000',
}))).toThrow(/WEB_ORIGIN\[0\] must use HTTPS in production/)
})
it('requires the public storage endpoint to be a bare origin', () => {
expect(() => parseConfig(productionEnvironment({
S3_PUBLIC_ENDPOINT: 'https://media.frameflow.cn/private-path',
}))).toThrow('S3_PUBLIC_ENDPOINT must be an origin')
})
it('requires distinct signing secrets', () => {
const sharedSecret = 'shared-signing-secret-with-32-characters'
expect(() => parseConfig(productionEnvironment({
JWT_ACCESS_SECRET: sharedSecret,
JWT_REFRESH_SECRET: sharedSecret,
}))).toThrow('JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different')
})
it('rejects Replicate versions without an API token', () => {
expect(() => parseConfig(productionEnvironment({
REPLICATE_VIDEO_VERSION: 'video-model-version',
}))).toThrow('REPLICATE_API_TOKEN is required')
})
it('bounds the Replicate prediction deadline to the provider-supported range', () => {
expect(() => parseConfig(productionEnvironment({
REPLICATE_PREDICTION_DEADLINE_SECONDS: '4',
}))).toThrow('REPLICATE_PREDICTION_DEADLINE_SECONDS')
expect(() => parseConfig(productionEnvironment({
REPLICATE_PREDICTION_DEADLINE_SECONDS: '86401',
}))).toThrow('REPLICATE_PREDICTION_DEADLINE_SECONDS')
})
it('requires each generic provider capability to be configured as a complete triple', () => {
expect(() => parseConfig(productionEnvironment({
GENERIC_IMAGE_API_KEY: 'generic-image-secret',
GENERIC_IMAGE_MODEL: 'vendor-image-model',
}))).toThrow('GENERIC_IMAGE requires API_KEY, BASE_URL, and MODEL together')
const result = parseConfig(productionEnvironment({
GENERIC_TEXT_API_KEY: 'generic-text-secret',
GENERIC_TEXT_BASE_URL: 'https://text.vendor.cn/v1',
GENERIC_TEXT_MODEL: 'vendor-text-model',
}))
expect(result.GENERIC_TEXT_BASE_URL).toBe('https://text.vendor.cn/v1')
expect(() => parseConfig(productionEnvironment({
GENERIC_VIDEO_API_KEY: 'generic-video-secret',
GENERIC_VIDEO_BASE_URL: 'https://video.vendor.cn/v1?route=private',
GENERIC_VIDEO_MODEL: 'vendor-video-model',
}))).toThrow('GENERIC_VIDEO_BASE_URL must not contain credentials, query parameters, or a fragment')
})
it('rejects a partially configured Stripe integration', () => {
expect(() => parseConfig(productionEnvironment({
STRIPE_SECRET_KEY: 'sk_live_frameflow',
STRIPE_WEBHOOK_SECRET: 'whsec_frameflow',
}))).toThrow('Stripe production billing must configure')
})
it('requires independent credential encryption and complete publishing connector configuration', () => {
const accessSecret = 'access-secret-with-at-least-32-characters'
expect(() => parseConfig(productionEnvironment({
JWT_ACCESS_SECRET: accessSecret,
CREDENTIAL_ENCRYPTION_KEY: accessSecret,
}))).toThrow('CREDENTIAL_ENCRYPTION_KEY must be distinct')
expect(() => parseConfig(productionEnvironment({
PUBLISHING_CONNECTOR_URL: 'https://publisher.frameflow.cn',
}))).toThrow('Publishing connector must configure')
})
})

279
server/src/config.ts Normal file
View File

@@ -0,0 +1,279 @@
import 'dotenv/config'
import { z } from 'zod'
const booleanFromEnv = z.preprocess((value) => {
if (typeof value === 'string') return value.toLowerCase() === 'true'
return value
}, z.boolean())
const optionalSecret = (minimumLength = 1) => z.preprocess(
(value) => typeof value === 'string' && value.trim() === '' ? undefined : value,
z.string().min(minimumLength).optional(),
)
const optionalUrl = z.preprocess(
(value) => typeof value === 'string' && value.trim() === '' ? undefined : value,
z.string().url().optional(),
)
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
API_HOST: z.string().default('127.0.0.1'),
API_PORT: z.coerce.number().int().positive().default(8787),
API_PUBLIC_ORIGIN: z.string().url().default('http://127.0.0.1:8787'),
WEB_ORIGIN: z.string().default('http://127.0.0.1:4173'),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
METRICS_HOST: z.string().default('127.0.0.1'),
WORKER_METRICS_PORT: z.coerce.number().int().positive().default(9092),
WORKER_HEARTBEAT_KEY: z.string().min(1).default('frameflow:worker:heartbeat'),
WORKER_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
WORKER_HEARTBEAT_TTL_SECONDS: z.coerce.number().int().min(5).default(20),
METRICS_TOKEN: optionalSecret(20),
DATABASE_URL: z.string().url().default('postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow'),
REDIS_URL: z.string().url().default('redis://127.0.0.1:63799'),
JWT_ACCESS_SECRET: z.string().min(32).default('local-access-secret-replace-before-prod'),
JWT_REFRESH_SECRET: z.string().min(32).default('local-refresh-secret-replace-before-prod'),
CREDENTIAL_ENCRYPTION_KEY: z.string().min(32).default('local-credential-encryption-key-replace-before-prod'),
ACCESS_TOKEN_TTL: z.string().default('15m'),
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
EMAIL_VERIFICATION_REQUIRED: booleanFromEnv.optional(),
EMAIL_VERIFICATION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(24),
PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().min(5).max(1440).default(30),
RESEND_API_KEY: optionalSecret(),
EMAIL_FROM: z.string().trim().min(3).default('FrameFlow <noreply@frameflow.local>'),
S3_ENDPOINT: z.string().url().default('http://127.0.0.1:9000'),
S3_PUBLIC_ENDPOINT: z.string().url().optional(),
S3_REGION: z.string().default('us-east-1'),
S3_BUCKET: z.string().default('frameflow'),
S3_ACCESS_KEY: z.string().default('frameflow'),
S3_SECRET_KEY: z.string().default('frameflow-local-secret'),
S3_FORCE_PATH_STYLE: booleanFromEnv.default(true),
OPENAI_API_KEY: optionalSecret(),
OPENAI_TEXT_MODEL: z.string().default('gpt-5.6-sol'),
OPENAI_IMAGE_MODEL: z.string().default('gpt-image-2'),
OPENAI_TTS_MODEL: z.string().default('gpt-4o-mini-tts'),
OPENAI_TTS_VOICE: z.string().default('alloy'),
AGNES_API_KEY: optionalSecret(),
AGNES_TEXT_MODEL: z.string().default('agnes-2.5-flash'),
AGNES_IMAGE_MODEL: z.string().default('agnes-image-2.1-flash'),
AGNES_VIDEO_MODEL: z.string().default('agnes-video-v2.0'),
AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS: z.coerce.number().int().min(30).max(86_400).default(900),
AGNES_VIDEO_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).max(60_000).default(4000),
GENERIC_TEXT_API_KEY: optionalSecret(),
GENERIC_TEXT_BASE_URL: optionalUrl,
GENERIC_TEXT_MODEL: optionalSecret(),
GENERIC_IMAGE_API_KEY: optionalSecret(),
GENERIC_IMAGE_BASE_URL: optionalUrl,
GENERIC_IMAGE_MODEL: optionalSecret(),
GENERIC_VIDEO_API_KEY: optionalSecret(),
GENERIC_VIDEO_BASE_URL: optionalUrl,
GENERIC_VIDEO_MODEL: optionalSecret(),
GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS: z.coerce.number().int().min(5).max(86_400).default(900),
GENERIC_VIDEO_POLL_INTERVAL_MS: z.coerce.number().int().min(250).max(60_000).default(4000),
REPLICATE_API_TOKEN: optionalSecret(),
REPLICATE_IMAGE_VERSION: optionalSecret(),
REPLICATE_VIDEO_VERSION: optionalSecret(),
REPLICATE_LIPSYNC_VERSION: optionalSecret(),
REPLICATE_PREDICTION_DEADLINE_SECONDS: z.coerce.number().int().min(5).max(86_400).default(900),
STRIPE_SECRET_KEY: optionalSecret(),
STRIPE_WEBHOOK_SECRET: optionalSecret(),
STRIPE_PRO_PRICE_ID: optionalSecret(),
STRIPE_STUDIO_PRICE_ID: optionalSecret(),
STRIPE_PORTAL_CONFIGURATION_ID: optionalSecret(),
BILLING_SUCCESS_URL: optionalUrl,
BILLING_CANCEL_URL: optionalUrl,
PUBLISHING_CONNECTOR_URL: optionalUrl,
PUBLISHING_CONNECTOR_SECRET: optionalSecret(24),
PUBLISHING_CONNECTOR_TIMEOUT_MS: z.coerce.number().int().min(1000).max(120_000).default(30_000),
FFMPEG_PATH: z.string().default('ffmpeg'),
FFPROBE_PATH: z.string().default('ffprobe'),
WORKER_CONCURRENCY: z.coerce.number().int().positive().max(20).default(2),
MAX_UPLOAD_BYTES: z.coerce.number().int().positive().default(100 * 1024 * 1024),
PROVIDER_DOWNLOAD_TIMEOUT_MS: z.coerce.number().int().min(1000).max(600_000).default(180_000),
PROVIDER_DOWNLOAD_MAX_REDIRECTS: z.coerce.number().int().min(0).max(10).default(3),
})
type ParsedConfig = z.infer<typeof schema>
function parseUrl(name: string, value: string, issues: string[]) {
try {
return new URL(value)
} catch {
issues.push(`${name} must be a valid URL`)
return null
}
}
function isLocalHostname(hostname: string) {
const normalized = hostname.toLowerCase()
return normalized === 'localhost'
|| normalized === '127.0.0.1'
|| normalized === '0.0.0.0'
|| normalized === '::1'
}
function isDocumentationHostname(hostname: string) {
const normalized = hostname.toLowerCase()
return normalized === 'example.com' || normalized.endsWith('.example.com')
}
function looksLikePlaceholder(value: string) {
const normalized = value.trim().toLowerCase()
return normalized.startsWith('replace_')
|| normalized.startsWith('replace-')
|| normalized.includes('replace-before-prod')
|| normalized.includes('change-me')
|| normalized.includes('changeme')
|| normalized === 'frameflow-local-secret'
}
function validateOrigin(name: string, value: string, issues: string[]) {
const url = parseUrl(name, value, issues)
if (!url) return
if (url.protocol !== 'https:') issues.push(`${name} must use HTTPS in production`)
if (isLocalHostname(url.hostname) || isDocumentationHostname(url.hostname)) {
issues.push(`${name} must use a real public hostname in production`)
}
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
issues.push(`${name} must be an origin without credentials, path, query, or fragment`)
}
}
function validatePublicUrl(name: string, value: string | undefined, issues: string[], required = false) {
if (!value) {
if (required) issues.push(`${name} is required in production`)
return
}
const url = parseUrl(name, value, issues)
if (!url) return
if (url.protocol !== 'https:') issues.push(`${name} must use HTTPS in production`)
if (isLocalHostname(url.hostname) || isDocumentationHostname(url.hostname)) {
issues.push(`${name} must use a real public hostname in production`)
}
}
function validateProviderBaseUrl(name: string, value: string, issues: string[]) {
validatePublicUrl(name, value, issues, true)
const url = parseUrl(name, value, issues)
if (!url) return
if (url.username || url.password || url.search || url.hash) {
issues.push(`${name} must not contain credentials, query parameters, or a fragment`)
}
}
function validateServiceUrl(name: string, value: string, protocols: string[], issues: string[]) {
const url = parseUrl(name, value, issues)
if (!url) return
if (!protocols.includes(url.protocol)) issues.push(`${name} must use ${protocols.join(' or ')}`)
if (isLocalHostname(url.hostname)) issues.push(`${name} must not target localhost in production`)
if (!url.username) issues.push(`${name} must include a username in production`)
if (!url.password) issues.push(`${name} must include authentication credentials in production`)
if (looksLikePlaceholder(url.password)) issues.push(`${name} contains a placeholder password`)
}
function validateProductionConfiguration(data: ParsedConfig) {
const issues: string[] = []
const origins = data.WEB_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean)
if (origins.length === 0) issues.push('WEB_ORIGIN must include at least one origin')
origins.forEach((origin, index) => validateOrigin(`WEB_ORIGIN[${index}]`, origin, issues))
validateOrigin('API_PUBLIC_ORIGIN', data.API_PUBLIC_ORIGIN, issues)
validateServiceUrl('DATABASE_URL', data.DATABASE_URL, ['postgres:', 'postgresql:'], issues)
validateServiceUrl('REDIS_URL', data.REDIS_URL, ['redis:', 'rediss:'], issues)
if (data.S3_PUBLIC_ENDPOINT) validateOrigin('S3_PUBLIC_ENDPOINT', data.S3_PUBLIC_ENDPOINT, issues)
else issues.push('S3_PUBLIC_ENDPOINT is required in production')
validatePublicUrl('BILLING_SUCCESS_URL', data.BILLING_SUCCESS_URL, issues)
validatePublicUrl('BILLING_CANCEL_URL', data.BILLING_CANCEL_URL, issues)
const storageEndpoint = parseUrl('S3_ENDPOINT', data.S3_ENDPOINT, issues)
if (storageEndpoint) {
if (!['http:', 'https:'].includes(storageEndpoint.protocol)) issues.push('S3_ENDPOINT must use http: or https:')
if (isLocalHostname(storageEndpoint.hostname)) issues.push('S3_ENDPOINT must not target localhost in production')
}
const secretValues: Array<[string, string | undefined]> = [
['JWT_ACCESS_SECRET', data.JWT_ACCESS_SECRET],
['JWT_REFRESH_SECRET', data.JWT_REFRESH_SECRET],
['CREDENTIAL_ENCRYPTION_KEY', data.CREDENTIAL_ENCRYPTION_KEY],
['S3_ACCESS_KEY', data.S3_ACCESS_KEY],
['S3_SECRET_KEY', data.S3_SECRET_KEY],
['RESEND_API_KEY', data.RESEND_API_KEY],
['OPENAI_API_KEY', data.OPENAI_API_KEY],
['AGNES_API_KEY', data.AGNES_API_KEY],
['GENERIC_TEXT_API_KEY', data.GENERIC_TEXT_API_KEY],
['GENERIC_IMAGE_API_KEY', data.GENERIC_IMAGE_API_KEY],
['GENERIC_VIDEO_API_KEY', data.GENERIC_VIDEO_API_KEY],
['REPLICATE_API_TOKEN', data.REPLICATE_API_TOKEN],
['STRIPE_SECRET_KEY', data.STRIPE_SECRET_KEY],
['STRIPE_WEBHOOK_SECRET', data.STRIPE_WEBHOOK_SECRET],
['STRIPE_PRO_PRICE_ID', data.STRIPE_PRO_PRICE_ID],
['STRIPE_STUDIO_PRICE_ID', data.STRIPE_STUDIO_PRICE_ID],
['PUBLISHING_CONNECTOR_SECRET', data.PUBLISHING_CONNECTOR_SECRET],
]
secretValues.forEach(([name, value]) => {
if (value && looksLikePlaceholder(value)) issues.push(`${name} contains a placeholder value`)
})
if (data.JWT_ACCESS_SECRET === data.JWT_REFRESH_SECRET) {
issues.push('JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different')
}
if (data.CREDENTIAL_ENCRYPTION_KEY === data.JWT_ACCESS_SECRET || data.CREDENTIAL_ENCRYPTION_KEY === data.JWT_REFRESH_SECRET) {
issues.push('CREDENTIAL_ENCRYPTION_KEY must be distinct from JWT signing secrets')
}
if (data.S3_SECRET_KEY.length < 16) issues.push('S3_SECRET_KEY must contain at least 16 characters in production')
const emailVerificationRequired = data.EMAIL_VERIFICATION_REQUIRED ?? true
if (emailVerificationRequired && !data.RESEND_API_KEY) {
issues.push('RESEND_API_KEY is required when production email verification is enabled')
}
if (data.RESEND_API_KEY && (/frameflow\.local/i.test(data.EMAIL_FROM) || /@example\.com/i.test(data.EMAIL_FROM))) {
issues.push('EMAIL_FROM must use a verified non-placeholder sender in production')
}
const replicateVersions = [data.REPLICATE_IMAGE_VERSION, data.REPLICATE_VIDEO_VERSION, data.REPLICATE_LIPSYNC_VERSION]
if (replicateVersions.some(Boolean) && !data.REPLICATE_API_TOKEN) {
issues.push('REPLICATE_API_TOKEN is required when a Replicate model version is configured')
}
const genericCapabilities = [
['GENERIC_TEXT', data.GENERIC_TEXT_API_KEY, data.GENERIC_TEXT_BASE_URL, data.GENERIC_TEXT_MODEL],
['GENERIC_IMAGE', data.GENERIC_IMAGE_API_KEY, data.GENERIC_IMAGE_BASE_URL, data.GENERIC_IMAGE_MODEL],
['GENERIC_VIDEO', data.GENERIC_VIDEO_API_KEY, data.GENERIC_VIDEO_BASE_URL, data.GENERIC_VIDEO_MODEL],
] as const
for (const [name, apiKey, baseUrl, model] of genericCapabilities) {
const values = [apiKey, baseUrl, model]
if (values.some(Boolean) && values.some((value) => !value)) {
issues.push(`${name} requires API_KEY, BASE_URL, and MODEL together`)
}
if (baseUrl) validateProviderBaseUrl(`${name}_BASE_URL`, baseUrl, issues)
}
const stripeValues = [data.STRIPE_SECRET_KEY, data.STRIPE_WEBHOOK_SECRET, data.STRIPE_PRO_PRICE_ID, data.STRIPE_STUDIO_PRICE_ID]
if (stripeValues.some(Boolean) && stripeValues.some((value) => !value)) {
issues.push('Stripe production billing must configure STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID, and STRIPE_STUDIO_PRICE_ID together')
}
const publishingConnectorValues = [data.PUBLISHING_CONNECTOR_URL, data.PUBLISHING_CONNECTOR_SECRET]
if (publishingConnectorValues.some(Boolean) && publishingConnectorValues.some((value) => !value)) {
issues.push('Publishing connector must configure PUBLISHING_CONNECTOR_URL and PUBLISHING_CONNECTOR_SECRET together')
}
if (data.PUBLISHING_CONNECTOR_URL) {
validatePublicUrl('PUBLISHING_CONNECTOR_URL', data.PUBLISHING_CONNECTOR_URL, issues, true)
}
if (issues.length > 0) throw new Error(`Unsafe production environment configuration:\n${issues.join('\n')}`)
}
export function parseConfig(environment: NodeJS.ProcessEnv) {
const parsed = schema.safeParse(environment)
if (!parsed.success) {
const details = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('\n')
throw new Error(`Invalid environment configuration:\n${details}`)
}
if (parsed.data.NODE_ENV === 'production') validateProductionConfiguration(parsed.data)
const emailVerificationRequired = parsed.data.EMAIL_VERIFICATION_REQUIRED ?? parsed.data.NODE_ENV === 'production'
return { ...parsed.data, EMAIL_VERIFICATION_REQUIRED: emailVerificationRequired }
}
export const config = parseConfig(process.env)
export type Config = typeof config

17
server/src/db/client.ts Normal file
View File

@@ -0,0 +1,17 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { config } from '../config.js'
import * as schema from './schema.js'
export const sqlClient = postgres(config.DATABASE_URL, {
max: config.NODE_ENV === 'test' ? 2 : 10,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
})
export const db = drizzle(sqlClient, { schema })
export async function closeDatabase() {
await sqlClient.end({ timeout: 5 })
}

9
server/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,9 @@
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import { closeDatabase, db } from './client.js'
try {
await migrate(db, { migrationsFolder: new URL('../../drizzle', import.meta.url).pathname })
console.info('Database migrations applied')
} finally {
await closeDatabase()
}

566
server/src/db/schema.ts Normal file
View File

@@ -0,0 +1,566 @@
import {
boolean,
index,
integer,
jsonb,
numeric,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core'
const timestamps = {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}
export const membershipRole = pgEnum('membership_role', [
'OWNER',
'ADMIN',
'EDITOR',
'REVIEWER',
'VIEWER',
])
export const projectStatus = pgEnum('project_status', ['DRAFT', 'ACTIVE', 'ARCHIVED'])
export const episodeStatus = pgEnum('episode_status', [
'OUTLINE',
'SCRIPTING',
'STORYBOARDING',
'PRODUCTION',
'REVIEW',
'PUBLISHED',
])
export const assetType = pgEnum('asset_type', ['CHARACTER', 'SCENE', 'PROP', 'VOICE', 'MUSIC', 'SHOT'])
export const assetStatus = pgEnum('asset_status', ['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'ARCHIVED'])
export const shotStatus = pgEnum('shot_status', ['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'RENDERED'])
export const jobType = pgEnum('job_type', [
'PIPELINE_HEALTHCHECK',
'SCRIPT_GENERATE',
'STORYBOARD_GENERATE',
'ASSET_GENERATE',
'TTS_GENERATE',
'VIDEO_GENERATE',
'LIPSYNC_GENERATE',
'EPISODE_RENDER',
'DELIVERY_PUBLISH',
])
export const jobStatus = pgEnum('job_status', ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'])
export const productionRunStatus = pgEnum('production_run_status', ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED'])
export const reviewStatus = pgEnum('review_status', ['OPEN', 'APPROVED', 'CHANGES_REQUESTED', 'CANCELLED'])
export const reviewTargetType = pgEnum('review_target_type', ['SCRIPT_VERSION', 'ASSET_VERSION', 'SHOT_VERSION', 'RENDER'])
export const renderStatus = pgEnum('render_status', ['QUEUED', 'RENDERING', 'REVIEW', 'APPROVED', 'FAILED'])
export const deliveryStatus = pgEnum('delivery_status', ['DRAFT', 'QUEUED', 'PUBLISHING', 'PUBLISHED', 'FAILED', 'CANCELLED'])
export const accountTokenType = pgEnum('account_token_type', ['EMAIL_VERIFICATION', 'PASSWORD_RESET'])
export const usageKind = pgEnum('usage_kind', [
'TEXT_INPUT_TOKEN',
'TEXT_OUTPUT_TOKEN',
'IMAGE',
'AUDIO_SECOND',
'VIDEO_SECOND',
'RENDER_SECOND',
'STORAGE_BYTE_MONTH',
])
export const notificationTone = pgEnum('notification_tone', ['INFO', 'SUCCESS', 'WARNING', 'ERROR'])
export const publishingChannelStatus = pgEnum('publishing_channel_status', ['CONNECTED', 'EXPIRED', 'REVOKED', 'ERROR'])
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 320 }).notNull(),
displayName: varchar('display_name', { length: 120 }).notNull(),
passwordHash: text('password_hash').notNull(),
avatarKey: text('avatar_key'),
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
disabledAt: timestamp('disabled_at', { withTimezone: true }),
...timestamps,
}, (table) => [uniqueIndex('users_email_unique').on(table.email)])
export const workspaces = pgTable('workspaces', {
id: uuid('id').defaultRandom().primaryKey(),
name: varchar('name', { length: 160 }).notNull(),
slug: varchar('slug', { length: 80 }).notNull(),
ownerId: uuid('owner_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
plan: varchar('plan', { length: 32 }).default('PRO').notNull(),
monthlyCreditLimit: integer('monthly_credit_limit').default(10000).notNull(),
settings: jsonb('settings').$type<Record<string, unknown>>().default({}).notNull(),
...timestamps,
}, (table) => [uniqueIndex('workspaces_slug_unique').on(table.slug), index('workspaces_owner_idx').on(table.ownerId)])
export const billingSubscriptions = pgTable('billing_subscriptions', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
provider: varchar('provider', { length: 32 }).default('stripe').notNull(),
customerId: varchar('customer_id', { length: 255 }).notNull(),
subscriptionId: varchar('subscription_id', { length: 255 }),
priceId: varchar('price_id', { length: 255 }),
plan: varchar('plan', { length: 32 }),
status: varchar('status', { length: 48 }).default('no_subscription').notNull(),
currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }),
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
lastEventCreatedAt: timestamp('last_event_created_at', { withTimezone: true }),
...timestamps,
}, (table) => [
uniqueIndex('billing_subscriptions_workspace_unique').on(table.workspaceId),
uniqueIndex('billing_subscriptions_customer_unique').on(table.customerId),
uniqueIndex('billing_subscriptions_subscription_unique').on(table.subscriptionId),
index('billing_subscriptions_status_idx').on(table.status),
])
export const billingWebhookEvents = pgTable('billing_webhook_events', {
eventId: varchar('event_id', { length: 255 }).primaryKey(),
provider: varchar('provider', { length: 32 }).default('stripe').notNull(),
eventType: varchar('event_type', { length: 160 }).notNull(),
livemode: boolean('livemode').default(false).notNull(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }),
providerCreatedAt: timestamp('provider_created_at', { withTimezone: true }).notNull(),
processedAt: timestamp('processed_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index('billing_webhook_events_workspace_idx').on(table.workspaceId, table.processedAt),
index('billing_webhook_events_type_idx').on(table.eventType),
])
export const memberships = pgTable('memberships', {
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: membershipRole('role').default('VIEWER').notNull(),
invitedById: uuid('invited_by_id').references(() => users.id, { onDelete: 'set null' }),
joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
primaryKey({ columns: [table.workspaceId, table.userId] }),
index('memberships_user_idx').on(table.userId),
])
export const providerVerifications = pgTable('provider_verifications', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
provider: varchar('provider', { length: 32 }).$type<'openai' | 'replicate' | 'agnes' | 'generic'>().notNull(),
status: varchar('status', { length: 32 }).$type<'VERIFIED' | 'FAILED'>().notNull(),
configurationFingerprint: varchar('configuration_fingerprint', { length: 64 }).notNull(),
checkedAt: timestamp('checked_at', { withTimezone: true }).defaultNow().notNull(),
latencyMs: integer('latency_ms'),
errorCode: varchar('error_code', { length: 80 }),
message: text('message'),
details: jsonb('details').$type<Record<string, unknown>>().default({}).notNull(),
checkedById: uuid('checked_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
...timestamps,
}, (table) => [
uniqueIndex('provider_verifications_workspace_provider_unique').on(table.workspaceId, table.provider),
index('provider_verifications_workspace_status_idx').on(table.workspaceId, table.status),
])
export const workspaceInvitations = pgTable('workspace_invitations', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
email: varchar('email', { length: 320 }).notNull(),
role: membershipRole('role').default('VIEWER').notNull(),
tokenHash: text('token_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
acceptedById: uuid('accepted_by_id').references(() => users.id, { onDelete: 'set null' }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
invitedById: uuid('invited_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
...timestamps,
}, (table) => [
uniqueIndex('workspace_invitations_token_hash_unique').on(table.tokenHash),
index('workspace_invitations_workspace_email_idx').on(table.workspaceId, table.email),
index('workspace_invitations_workspace_created_idx').on(table.workspaceId, table.createdAt),
])
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
tokenHash: text('token_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
replacedById: uuid('replaced_by_id'),
userAgent: text('user_agent'),
ipAddress: varchar('ip_address', { length: 64 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [uniqueIndex('refresh_tokens_hash_unique').on(table.tokenHash), index('refresh_tokens_user_idx').on(table.userId)])
export const accountActionTokens = pgTable('account_action_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
type: accountTokenType('type').notNull(),
tokenHash: text('token_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
consumedAt: timestamp('consumed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
uniqueIndex('account_action_tokens_hash_unique').on(table.tokenHash),
index('account_action_tokens_user_type_idx').on(table.userId, table.type),
index('account_action_tokens_expiry_idx').on(table.expiresAt),
])
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
name: varchar('name', { length: 180 }).notNull(),
slug: varchar('slug', { length: 100 }).notNull(),
logline: text('logline').default('').notNull(),
genre: varchar('genre', { length: 80 }).default('都市悬疑').notNull(),
visualBible: jsonb('visual_bible').$type<Record<string, unknown>>().default({}).notNull(),
status: projectStatus('status').default('DRAFT').notNull(),
...timestamps,
}, (table) => [
uniqueIndex('projects_workspace_slug_unique').on(table.workspaceId, table.slug),
index('projects_workspace_idx').on(table.workspaceId),
])
export const episodes = pgTable('episodes', {
id: uuid('id').defaultRandom().primaryKey(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
episodeNumber: integer('episode_number').notNull(),
title: varchar('title', { length: 200 }).notNull(),
synopsis: text('synopsis').default('').notNull(),
targetDurationSeconds: integer('target_duration_seconds').default(180).notNull(),
status: episodeStatus('status').default('OUTLINE').notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
...timestamps,
}, (table) => [
uniqueIndex('episodes_project_number_unique').on(table.projectId, table.episodeNumber),
index('episodes_project_idx').on(table.projectId),
])
export const scriptVersions = pgTable('script_versions', {
id: uuid('id').defaultRandom().primaryKey(),
episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
title: varchar('title', { length: 200 }).notNull(),
content: jsonb('content').$type<Record<string, unknown>>().notNull(),
plainText: text('plain_text').default('').notNull(),
changeSummary: text('change_summary').default('').notNull(),
isCurrent: boolean('is_current').default(false).notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
uniqueIndex('script_versions_episode_version_unique').on(table.episodeId, table.version),
index('script_versions_episode_current_idx').on(table.episodeId, table.isCurrent),
])
export const assets = pgTable('assets', {
id: uuid('id').defaultRandom().primaryKey(),
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
type: assetType('type').notNull(),
name: varchar('name', { length: 180 }).notNull(),
description: text('description').default('').notNull(),
status: assetStatus('status').default('DRAFT').notNull(),
tags: jsonb('tags').$type<string[]>().default([]).notNull(),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}).notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
...timestamps,
}, (table) => [index('assets_project_type_idx').on(table.projectId, table.type), index('assets_project_status_idx').on(table.projectId, table.status)])
export const assetVersions = pgTable('asset_versions', {
id: uuid('id').defaultRandom().primaryKey(),
assetId: uuid('asset_id').notNull().references(() => assets.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
storageKey: text('storage_key'),
thumbnailKey: text('thumbnail_key'),
mimeType: varchar('mime_type', { length: 120 }),
byteSize: integer('byte_size'),
width: integer('width'),
height: integer('height'),
durationMs: integer('duration_ms'),
prompt: text('prompt').default('').notNull(),
negativePrompt: text('negative_prompt').default('').notNull(),
provider: varchar('provider', { length: 80 }),
providerModel: varchar('provider_model', { length: 160 }),
seed: varchar('seed', { length: 80 }),
generationParams: jsonb('generation_params').$type<Record<string, unknown>>().default({}).notNull(),
isCurrent: boolean('is_current').default(false).notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
uniqueIndex('asset_versions_asset_version_unique').on(table.assetId, table.version),
index('asset_versions_current_idx').on(table.assetId, table.isCurrent),
])
export const shots = pgTable('shots', {
id: uuid('id').defaultRandom().primaryKey(),
episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }),
shotNumber: integer('shot_number').notNull(),
sceneNumber: integer('scene_number').notNull(),
title: varchar('title', { length: 180 }).notNull(),
description: text('description').default('').notNull(),
shotType: varchar('shot_type', { length: 50 }).default('中景').notNull(),
cameraMotion: varchar('camera_motion', { length: 50 }).default('静止').notNull(),
durationMs: integer('duration_ms').default(3000).notNull(),
status: shotStatus('status').default('DRAFT').notNull(),
characterAssetIds: jsonb('character_asset_ids').$type<string[]>().default([]).notNull(),
sceneAssetId: uuid('scene_asset_id').references(() => assets.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
...timestamps,
}, (table) => [
uniqueIndex('shots_episode_number_unique').on(table.episodeId, table.shotNumber),
index('shots_episode_sort_idx').on(table.episodeId, table.sortOrder),
])
export const shotVersions = pgTable('shot_versions', {
id: uuid('id').defaultRandom().primaryKey(),
shotId: uuid('shot_id').notNull().references(() => shots.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
imageAssetVersionId: uuid('image_asset_version_id').references(() => assetVersions.id, { onDelete: 'set null' }),
videoStorageKey: text('video_storage_key'),
audioStorageKey: text('audio_storage_key'),
subtitleText: text('subtitle_text').default('').notNull(),
generationParams: jsonb('generation_params').$type<Record<string, unknown>>().default({}).notNull(),
isCurrent: boolean('is_current').default(false).notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [uniqueIndex('shot_versions_shot_version_unique').on(table.shotId, table.version), index('shot_versions_current_idx').on(table.shotId, table.isCurrent)])
export interface RenderSourceManifest {
schemaVersion: 1
capturedAt: string
shots: Array<{
shotId: string
shotVersionId: string
shotNumber: number
shotVersion: number
sortOrder: number
durationMs: number
subtitleText: string
imageAssetVersionId: string | null
}>
}
export const renders = pgTable('renders', {
id: uuid('id').defaultRandom().primaryKey(),
episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
status: renderStatus('status').default('QUEUED').notNull(),
storageKey: text('storage_key'),
thumbnailKey: text('thumbnail_key'),
subtitleSrtKey: text('subtitle_srt_key'),
subtitleVttKey: text('subtitle_vtt_key'),
mimeType: varchar('mime_type', { length: 120 }).default('video/mp4').notNull(),
byteSize: integer('byte_size'),
durationMs: integer('duration_ms'),
width: integer('width').notNull(),
height: integer('height').notNull(),
fps: integer('fps').default(24).notNull(),
settings: jsonb('settings').$type<Record<string, unknown>>().default({}).notNull(),
sourceManifest: jsonb('source_manifest').$type<RenderSourceManifest>(),
qualityReport: jsonb('quality_report').$type<Record<string, unknown>>(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
...timestamps,
}, (table) => [uniqueIndex('renders_episode_version_unique').on(table.episodeId, table.version), index('renders_episode_status_idx').on(table.episodeId, table.status)])
export const publishingChannelAccounts = pgTable('publishing_channel_accounts', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
platform: varchar('platform', { length: 32 }).notNull(),
externalAccountId: varchar('external_account_id', { length: 255 }).notNull(),
displayName: varchar('display_name', { length: 180 }).notNull(),
avatarUrl: text('avatar_url'),
status: publishingChannelStatus('status').default('CONNECTED').notNull(),
accessTokenCiphertext: text('access_token_ciphertext'),
refreshTokenCiphertext: text('refresh_token_ciphertext'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
scopes: jsonb('scopes').$type<string[]>().default([]).notNull(),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}).notNull(),
connectedById: uuid('connected_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
lastVerifiedAt: timestamp('last_verified_at', { withTimezone: true }).defaultNow().notNull(),
errorCode: varchar('error_code', { length: 100 }),
errorMessage: text('error_message'),
...timestamps,
}, (table) => [
uniqueIndex('publishing_channel_accounts_external_unique').on(table.workspaceId, table.platform, table.externalAccountId),
index('publishing_channel_accounts_workspace_idx').on(table.workspaceId, table.platform),
index('publishing_channel_accounts_status_idx').on(table.workspaceId, table.status),
])
export const publishingOauthStates = pgTable('publishing_oauth_states', {
stateHash: varchar('state_hash', { length: 64 }).primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
platform: varchar('platform', { length: 32 }).notNull(),
codeVerifierCiphertext: text('code_verifier_ciphertext'),
returnPath: text('return_path').default('/?publishing=connected').notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
consumedAt: timestamp('consumed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index('publishing_oauth_states_expiry_idx').on(table.expiresAt),
index('publishing_oauth_states_workspace_idx').on(table.workspaceId, table.platform),
])
export const productionRuns = pgTable('production_runs', {
id: uuid('id').defaultRandom().primaryKey(),
episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }),
renderId: uuid('render_id').notNull().references(() => renders.id, { onDelete: 'cascade' }),
status: productionRunStatus('status').default('QUEUED').notNull(),
settings: jsonb('settings').$type<Record<string, unknown>>().default({}).notNull(),
totalJobs: integer('total_jobs').notNull(),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
...timestamps,
}, (table) => [
uniqueIndex('production_runs_render_unique').on(table.renderId),
index('production_runs_episode_status_idx').on(table.episodeId, table.status),
index('production_runs_episode_created_idx').on(table.episodeId, table.createdAt),
])
export const generationJobs = pgTable('generation_jobs', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
episodeId: uuid('episode_id').references(() => episodes.id, { onDelete: 'cascade' }),
productionRunId: uuid('production_run_id').references(() => productionRuns.id, { onDelete: 'set null' }),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
type: jobType('type').notNull(),
status: jobStatus('status').default('QUEUED').notNull(),
progress: integer('progress').default(0).notNull(),
provider: varchar('provider', { length: 80 }),
providerModel: varchar('provider_model', { length: 160 }),
externalId: varchar('external_id', { length: 255 }),
idempotencyKey: varchar('idempotency_key', { length: 180 }).notNull(),
input: jsonb('input').$type<Record<string, unknown>>().notNull(),
output: jsonb('output').$type<Record<string, unknown>>(),
errorCode: varchar('error_code', { length: 100 }),
errorMessage: text('error_message'),
attempts: integer('attempts').default(0).notNull(),
maxAttempts: integer('max_attempts').default(3).notNull(),
estimatedCredits: numeric('estimated_credits', { precision: 20, scale: 4 }).default('0').notNull(),
startedAt: timestamp('started_at', { withTimezone: true }),
completedAt: timestamp('completed_at', { withTimezone: true }),
...timestamps,
}, (table) => [
uniqueIndex('generation_jobs_idempotency_unique').on(table.workspaceId, table.idempotencyKey),
index('generation_jobs_workspace_status_idx').on(table.workspaceId, table.status),
index('generation_jobs_project_idx').on(table.projectId),
index('generation_jobs_production_run_idx').on(table.productionRunId),
])
export const reviewRequests = pgTable('review_requests', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
targetType: reviewTargetType('target_type').notNull(),
targetId: uuid('target_id').notNull(),
status: reviewStatus('status').default('OPEN').notNull(),
requestedById: uuid('requested_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
assignedToId: uuid('assigned_to_id').references(() => users.id, { onDelete: 'set null' }),
resolvedById: uuid('resolved_by_id').references(() => users.id, { onDelete: 'set null' }),
summary: text('summary').default('').notNull(),
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
...timestamps,
}, (table) => [index('review_requests_workspace_status_idx').on(table.workspaceId, table.status), index('review_requests_target_idx').on(table.targetType, table.targetId)])
export const reviewComments = pgTable('review_comments', {
id: uuid('id').defaultRandom().primaryKey(),
reviewRequestId: uuid('review_request_id').notNull().references(() => reviewRequests.id, { onDelete: 'cascade' }),
authorId: uuid('author_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
body: text('body').notNull(),
timecodeMs: integer('timecode_ms'),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [index('review_comments_request_idx').on(table.reviewRequestId)])
export const deliveries = pgTable('deliveries', {
id: uuid('id').defaultRandom().primaryKey(),
renderId: uuid('render_id').notNull().references(() => renders.id, { onDelete: 'cascade' }),
jobId: uuid('job_id').references(() => generationJobs.id, { onDelete: 'set null' }),
channelAccountId: uuid('channel_account_id').references(() => publishingChannelAccounts.id, { onDelete: 'cascade' }),
platform: varchar('platform', { length: 80 }).notNull(),
status: deliveryStatus('status').default('DRAFT').notNull(),
destination: jsonb('destination').$type<Record<string, unknown>>().default({}).notNull(),
externalId: varchar('external_id', { length: 255 }),
publishedUrl: text('published_url'),
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
publishedAt: timestamp('published_at', { withTimezone: true }),
lastProviderEventAt: timestamp('last_provider_event_at', { withTimezone: true }),
createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }),
errorMessage: text('error_message'),
...timestamps,
}, (table) => [
index('deliveries_render_idx').on(table.renderId),
index('deliveries_job_idx').on(table.jobId),
index('deliveries_channel_account_idx').on(table.channelAccountId),
index('deliveries_status_idx').on(table.status),
])
export const publishingWebhookEvents = pgTable('publishing_webhook_events', {
eventKey: varchar('event_key', { length: 320 }).primaryKey(),
eventId: varchar('event_id', { length: 255 }).notNull(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
platform: varchar('platform', { length: 32 }).notNull(),
deliveryId: uuid('delivery_id').references(() => deliveries.id, { onDelete: 'set null' }),
status: varchar('status', { length: 32 }).notNull(),
providerOccurredAt: timestamp('provider_occurred_at', { withTimezone: true }).notNull(),
payload: jsonb('payload').$type<Record<string, unknown>>().default({}).notNull(),
applied: boolean('applied').default(false).notNull(),
processedAt: timestamp('processed_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index('publishing_webhook_events_delivery_idx').on(table.deliveryId, table.providerOccurredAt),
index('publishing_webhook_events_workspace_idx').on(table.workspaceId, table.processedAt),
index('publishing_webhook_events_platform_idx').on(table.platform, table.processedAt),
])
export const usageLedger = pgTable('usage_ledger', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
jobId: uuid('job_id').references(() => generationJobs.id, { onDelete: 'set null' }),
kind: usageKind('kind').notNull(),
quantity: numeric('quantity', { precision: 20, scale: 4 }).notNull(),
credits: numeric('credits', { precision: 20, scale: 4 }).notNull(),
providerCostUsd: numeric('provider_cost_usd', { precision: 20, scale: 6 }),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}).notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [index('usage_ledger_workspace_time_idx').on(table.workspaceId, table.occurredAt), index('usage_ledger_job_idx').on(table.jobId)])
export const notifications = pgTable('notifications', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
kind: varchar('kind', { length: 80 }).notNull(),
tone: notificationTone('tone').default('INFO').notNull(),
title: varchar('title', { length: 240 }).notNull(),
body: text('body').default('').notNull(),
page: varchar('page', { length: 48 }).notNull(),
resourceType: varchar('resource_type', { length: 80 }),
resourceId: uuid('resource_id'),
dedupeKey: varchar('dedupe_key', { length: 255 }).notNull(),
metadata: jsonb('metadata').$type<Record<string, unknown>>().default({}).notNull(),
readAt: timestamp('read_at', { withTimezone: true }),
...timestamps,
}, (table) => [
uniqueIndex('notifications_user_dedupe_unique').on(table.userId, table.dedupeKey),
index('notifications_user_created_idx').on(table.userId, table.createdAt),
index('notifications_user_unread_idx').on(table.userId, table.readAt, table.createdAt),
index('notifications_workspace_created_idx').on(table.workspaceId, table.createdAt),
])
export const auditLogs = pgTable('audit_logs', {
id: uuid('id').defaultRandom().primaryKey(),
workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
actorId: uuid('actor_id').references(() => users.id, { onDelete: 'set null' }),
action: varchar('action', { length: 120 }).notNull(),
resourceType: varchar('resource_type', { length: 80 }).notNull(),
resourceId: uuid('resource_id'),
ipAddress: varchar('ip_address', { length: 64 }),
userAgent: text('user_agent'),
before: jsonb('before').$type<Record<string, unknown>>(),
after: jsonb('after').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [index('audit_logs_workspace_time_idx').on(table.workspaceId, table.createdAt), index('audit_logs_actor_idx').on(table.actorId)])
export type User = typeof users.$inferSelect
export type Workspace = typeof workspaces.$inferSelect
export type Project = typeof projects.$inferSelect
export type Episode = typeof episodes.$inferSelect
export type Asset = typeof assets.$inferSelect
export type GenerationJob = typeof generationJobs.$inferSelect
export type ProductionRun = typeof productionRuns.$inferSelect
export type Notification = typeof notifications.$inferSelect
export type ProviderVerification = typeof providerVerifications.$inferSelect

269
server/src/db/seed.ts Normal file
View File

@@ -0,0 +1,269 @@
import argon2 from 'argon2'
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { and, desc, eq, sql } from 'drizzle-orm'
import { closeDatabase, db } from './client.js'
import { createCharacterConsistencyProfile, readCharacterConsistencyProfile } from '../lib/character-consistency.js'
import { closeStorage, projectSeedObjectKey, putObject } from '../storage/client.js'
import {
assetVersions,
assets,
episodes,
memberships,
projects,
scriptVersions,
shotVersions,
shots,
users,
workspaces,
} from './schema.js'
const seedEmail = 'studio@frameflow.local'
async function seed() {
const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const passwordHash = await argon2.hash('FrameFlow2026!', { type: argon2.argon2id })
const [user] = await db.insert(users).values({
email: seedEmail,
displayName: '雾灯工作室',
passwordHash,
emailVerifiedAt: new Date(),
}).onConflictDoUpdate({
target: users.email,
set: { displayName: '雾灯工作室', passwordHash, updatedAt: new Date() },
}).returning()
if (!user) throw new Error('Failed to create seed user')
let [workspace] = await db.select().from(workspaces).where(eq(workspaces.slug, 'mistlight-studio')).limit(1)
if (!workspace) {
;[workspace] = await db.insert(workspaces).values({
name: '雾灯工作室',
slug: 'mistlight-studio',
ownerId: user.id,
}).returning()
}
if (!workspace) throw new Error('Failed to create seed workspace')
await db.insert(memberships).values({
workspaceId: workspace.id,
userId: user.id,
role: 'OWNER',
}).onConflictDoUpdate({
target: [memberships.workspaceId, memberships.userId],
set: { role: 'OWNER', updatedAt: new Date() },
})
let [project] = await db.select().from(projects).where(and(eq(projects.workspaceId, workspace.id), eq(projects.slug, 'mist-letters'))).limit(1)
if (!project) {
;[project] = await db.insert(projects).values({
workspaceId: workspace.id,
createdById: user.id,
name: '雾城来信',
slug: 'mist-letters',
logline: '调查记者循着匿名录音,揭开十年前失踪案与整座雾城的秘密。',
genre: '都市悬疑',
status: 'ACTIVE',
visualBible: {
style: '写实电影感漫画',
palette: ['冷灰', '雾蓝', '信号红'],
lighting: '潮湿雨夜、硬侧光、克制霓虹',
},
}).returning()
}
if (!project) throw new Error('Failed to create seed project')
let [episode] = await db.select().from(episodes).where(and(eq(episodes.projectId, project.id), eq(episodes.episodeNumber, 4))).limit(1)
if (!episode) {
;[episode] = await db.insert(episodes).values({
projectId: project.id,
episodeNumber: 4,
title: '暗房里的第二封信',
synopsis: '林遥循着录音中的钟声来到旧城天台,与陆澄第一次正面谈起十年前的失踪案。',
status: 'STORYBOARDING',
targetDurationSeconds: 190,
createdById: user.id,
}).returning()
}
if (!episode) throw new Error('Failed to create seed episode')
const [existingScript] = await db.select().from(scriptVersions).where(and(eq(scriptVersions.episodeId, episode.id), eq(scriptVersions.version, 1))).limit(1)
if (!existingScript) {
await db.insert(scriptVersions).values({
episodeId: episode.id,
version: 1,
title: episode.title,
isCurrent: true,
createdById: user.id,
changeSummary: '初始剧本',
plainText: '场 12 · 外 · 旧城天台 · 夜\n林遥:你知道我会来。\n陆澄:有些答案只能在下雨的时候被听见。',
content: {
scenes: [
{
number: 12,
location: '旧城天台',
time: '夜',
beats: [
{ type: 'action', text: '雨落得很急,铁门在风里撞向墙面。' },
{ type: 'dialogue', character: '林遥', text: '你知道我会来。' },
{ type: 'dialogue', character: '陆澄', text: '有些答案只能在下雨的时候被听见。' },
],
},
],
},
})
}
const assetSeeds = [
{
name: '林遥 · 侦探记者',
type: 'CHARACTER' as const,
description: '二十七岁调查记者,短发,克制而敏锐。',
filename: 'character-linyao.jpg',
identityPrompt: '二十七岁东亚女性调查记者,利落短发,克制敏锐的眼神,清晰稳定的面部轮廓。',
costumePrompt: '深色短款记者外套,简洁内搭,不佩戴夸张饰品。',
},
{
name: '陆澄 · 法医顾问',
type: 'CHARACTER' as const,
description: '三十二岁法医顾问,冷静,带有难以辨认的疲惫。',
filename: 'character-lucheng.jpg',
identityPrompt: '三十二岁东亚男性法医顾问,短发,冷静疲惫的眼神,清晰稳定的面部轮廓。',
costumePrompt: '深色衬衫与克制的职业外套,无醒目标识。',
},
{
name: '旧城天台 · 雨夜',
type: 'SCENE' as const,
description: '暴雨中的旧城高楼天台,雾与霓虹交叠。',
filename: 'scene-rooftop.jpg',
},
]
const seededAssets = new Map<string, { asset: typeof assets.$inferSelect, version: typeof assetVersions.$inferSelect }>()
for (const seedAsset of assetSeeds) {
const body = await readFile(resolve(workspaceRoot, 'public/assets', seedAsset.filename))
const key = projectSeedObjectKey(workspace.id, project.id, seedAsset.filename)
await putObject({
key,
body,
contentType: 'image/jpeg',
metadata: { source: 'seed', projectId: project.id },
})
let [asset] = await db.select().from(assets).where(and(eq(assets.projectId, project.id), eq(assets.name, seedAsset.name))).limit(1)
if (!asset) {
;[asset] = await db.insert(assets).values({
projectId: project.id,
type: seedAsset.type,
name: seedAsset.name,
description: seedAsset.description,
status: 'APPROVED',
tags: seedAsset.type === 'CHARACTER' ? ['主角'] : ['外景', '夜'],
createdById: user.id,
}).returning()
}
if (!asset) continue
let [version] = await db.select().from(assetVersions).where(and(eq(assetVersions.assetId, asset.id), eq(assetVersions.version, 1))).limit(1)
if (!version) {
;[version] = await db.insert(assetVersions).values({
assetId: asset.id,
version: 1,
storageKey: key,
mimeType: 'image/jpeg',
byteSize: body.length,
prompt: seedAsset.description,
provider: 'local-seed',
providerModel: 'bundled-example-v1',
generationParams: { source: 'seed', filename: seedAsset.filename },
isCurrent: true,
createdById: user.id,
}).returning()
} else if (version.storageKey === `seed/${seedAsset.filename}` || version.storageKey === key) {
;[version] = await db.update(assetVersions).set({
storageKey: key,
mimeType: 'image/jpeg',
byteSize: body.length,
provider: 'local-seed',
providerModel: 'bundled-example-v1',
generationParams: { ...(version.generationParams ?? {}), source: 'seed', filename: seedAsset.filename },
}).where(eq(assetVersions.id, version.id)).returning()
}
if (!version) throw new Error(`Failed to create seed version for ${seedAsset.name}`)
if (seedAsset.type === 'CHARACTER' && !readCharacterConsistencyProfile(asset.metadata)) {
const consistencyProfile = createCharacterConsistencyProfile({
referenceAssetVersionIds: [version.id],
identityPrompt: seedAsset.identityPrompt,
costumePrompt: seedAsset.costumePrompt,
stylePrompt: '写实电影感漫画,冷灰雾蓝色调,潮湿雨夜硬侧光。',
negativePrompt: '身份漂移,年龄变化,发型变化,畸形五官,文字,水印',
consistencyStrength: 90,
identityLocked: true,
}, user.id)
;[asset] = await db.update(assets).set({
metadata: { ...(asset.metadata ?? {}), consistencyProfile },
updatedAt: new Date(),
}).where(eq(assets.id, asset.id)).returning()
if (!asset) throw new Error(`Failed to create seed consistency profile for ${seedAsset.name}`)
}
seededAssets.set(seedAsset.name, { asset, version })
}
const linyao = seededAssets.get('林遥 · 侦探记者')
const lucheng = seededAssets.get('陆澄 · 法医顾问')
const rooftop = seededAssets.get('旧城天台 · 雨夜')
if (!linyao || !lucheng || !rooftop) throw new Error('Seed asset references are incomplete')
let [existingShot] = await db.select().from(shots).where(and(eq(shots.episodeId, episode.id), eq(shots.shotNumber, 1))).limit(1)
if (!existingShot) {
;[existingShot] = await db.insert(shots).values({
episodeId: episode.id,
shotNumber: 1,
sceneNumber: 12,
sortOrder: 100,
title: '雨夜旧城天台',
description: '暴雨掠过旧城天台,霓虹在积水中摇晃。',
shotType: '全景',
durationMs: 3200,
status: 'APPROVED',
characterAssetIds: [linyao.asset.id, lucheng.asset.id],
sceneAssetId: rooftop.asset.id,
createdById: user.id,
}).returning()
} else if (existingShot.characterAssetIds.length === 0 && !existingShot.sceneAssetId) {
;[existingShot] = await db.update(shots).set({
characterAssetIds: [linyao.asset.id, lucheng.asset.id],
sceneAssetId: rooftop.asset.id,
updatedAt: new Date(),
}).where(eq(shots.id, existingShot.id)).returning()
}
if (!existingShot) throw new Error('Failed to create seed shot')
const existingShotVersions = await db.select().from(shotVersions).where(eq(shotVersions.shotId, existingShot.id)).orderBy(desc(shotVersions.version))
if (!existingShotVersions.some((version) => version.generationParams?.source === 'seed')) {
await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`shot:${existingShot.id}`}))`)
const [latest] = await tx.select({ version: shotVersions.version }).from(shotVersions).where(eq(shotVersions.shotId, existingShot.id)).orderBy(desc(shotVersions.version)).limit(1)
await tx.update(shotVersions).set({ isCurrent: false }).where(eq(shotVersions.shotId, existingShot.id))
await tx.insert(shotVersions).values({
shotId: existingShot.id,
version: (latest?.version ?? 0) + 1,
imageAssetVersionId: rooftop.version.id,
subtitleText: '林遥:你知道我会来。\n陆澄:有些答案只能在下雨的时候被听见。',
generationParams: { source: 'seed', operation: 'example-storyboard' },
isCurrent: true,
createdById: user.id,
})
})
}
console.info(`Seed complete. Login: ${seedEmail} / FrameFlow2026!`)
}
try {
await seed()
} finally {
await closeDatabase()
closeStorage()
}

23
server/src/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import { buildApp } from './app.js'
import { config } from './config.js'
import { closeDatabase } from './db/client.js'
const app = await buildApp()
async function shutdown(signal: string) {
app.log.info({ signal }, 'Shutting down')
await app.close()
await closeDatabase()
process.exit(0)
}
process.on('SIGINT', () => void shutdown('SIGINT'))
process.on('SIGTERM', () => void shutdown('SIGTERM'))
try {
await app.listen({ host: config.API_HOST, port: config.API_PORT })
} catch (error) {
app.log.error(error)
await closeDatabase()
process.exit(1)
}

View File

@@ -0,0 +1,40 @@
import { randomBytes } from 'node:crypto'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { config } from '../config.js'
import { db } from '../db/client.js'
import { accountActionTokens } from '../db/schema.js'
import { hashToken } from './auth.js'
export type AccountTokenType = 'EMAIL_VERIFICATION' | 'PASSWORD_RESET'
function tokenTtlMs(type: AccountTokenType) {
return type === 'EMAIL_VERIFICATION'
? config.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1000
: config.PASSWORD_RESET_TTL_MINUTES * 60 * 1000
}
export async function issueAccountToken(userId: string, type: AccountTokenType) {
const token = randomBytes(32).toString('base64url')
const tokenHash = hashToken(token)
const now = new Date()
const expiresAt = new Date(now.getTime() + tokenTtlMs(type))
const record = await db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`account-token:${userId}:${type}`}))`)
await tx.update(accountActionTokens).set({ consumedAt: now }).where(and(
eq(accountActionTokens.userId, userId),
eq(accountActionTokens.type, type),
isNull(accountActionTokens.consumedAt),
))
const [created] = await tx.insert(accountActionTokens).values({
userId,
type,
tokenHash,
expiresAt,
}).returning()
if (!created) throw new Error('Failed to create account action token')
return created
})
return { token, record }
}

27
server/src/lib/audit.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { FastifyRequest } from 'fastify'
import { db } from '../db/client.js'
import { auditLogs } from '../db/schema.js'
import { requestMeta } from './http.js'
export async function audit(input: {
request: FastifyRequest
workspaceId?: string
action: string
resourceType: string
resourceId?: string
before?: Record<string, unknown>
after?: Record<string, unknown>
}) {
const meta = requestMeta(input.request)
await db.insert(auditLogs).values({
workspaceId: input.workspaceId,
actorId: input.request.user?.sub,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
before: input.before,
after: input.after,
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
})
}

100
server/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,100 @@
import { createHash, randomBytes } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { and, eq, gt, isNull } from 'drizzle-orm'
import { config } from '../config.js'
import { db } from '../db/client.js'
import { refreshTokens, users } from '../db/schema.js'
import { errors } from './errors.js'
export const REFRESH_COOKIE = 'ff_refresh'
export function hashToken(value: string) {
return createHash('sha256').update(value).digest('hex')
}
export function generateRefreshToken() {
return randomBytes(48).toString('base64url')
}
export function refreshCookieOptions() {
return {
httpOnly: true,
secure: config.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/api/v1/auth',
maxAge: config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60,
}
}
export function signAccessToken(app: FastifyInstance, user: { id: string; email: string; displayName: string }) {
return app.jwt.sign({
sub: user.id,
email: user.email,
displayName: user.displayName,
tokenType: 'access' as const,
}, { expiresIn: config.ACCESS_TOKEN_TTL })
}
export async function createRefreshSession(input: {
userId: string
userAgent?: string
ipAddress?: string
}) {
const token = generateRefreshToken()
const expiresAt = new Date(Date.now() + config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000)
const [session] = await db.insert(refreshTokens).values({
userId: input.userId,
tokenHash: hashToken(token),
expiresAt,
userAgent: input.userAgent,
ipAddress: input.ipAddress,
}).returning()
if (!session) throw new Error('Failed to create refresh session')
return { token, session }
}
export async function rotateRefreshSession(token: string, meta: { userAgent?: string; ipAddress?: string }) {
const [current] = await db.select().from(refreshTokens).where(and(
eq(refreshTokens.tokenHash, hashToken(token)),
isNull(refreshTokens.revokedAt),
gt(refreshTokens.expiresAt, new Date()),
)).limit(1)
if (!current) throw errors.unauthorized('Refresh session is invalid or expired')
const [user] = await db.select({
id: users.id,
email: users.email,
displayName: users.displayName,
emailVerifiedAt: users.emailVerifiedAt,
disabledAt: users.disabledAt,
}).from(users).where(eq(users.id, current.userId)).limit(1)
if (!user || user.disabledAt) throw errors.unauthorized('Account is unavailable')
if (config.EMAIL_VERIFICATION_REQUIRED && !user.emailVerifiedAt) throw errors.emailVerificationRequired()
const next = await createRefreshSession({ userId: user.id, ...meta })
await db.update(refreshTokens).set({
revokedAt: new Date(),
replacedById: next.session.id,
}).where(eq(refreshTokens.id, current.id))
return { user, token: next.token }
}
export async function revokeRefreshSession(token?: string) {
if (!token) return
await db.update(refreshTokens).set({ revokedAt: new Date() }).where(and(
eq(refreshTokens.tokenHash, hashToken(token)),
isNull(refreshTokens.revokedAt),
))
}
export async function requireAuth(request: FastifyRequest, _reply: FastifyReply) {
try {
await request.jwtVerify()
} catch {
throw errors.unauthorized()
}
if (request.user.tokenType !== 'access') throw errors.unauthorized()
}

View File

@@ -0,0 +1,18 @@
export const BILLING_PLAN_CATALOG = [
{ id: 'PRO', monthlyCreditLimit: 10_000, purchasable: true },
{ id: 'STUDIO', monthlyCreditLimit: 50_000, purchasable: true },
{ id: 'ENTERPRISE', monthlyCreditLimit: null, purchasable: false },
] as const
export type BillingPlanId = typeof BILLING_PLAN_CATALOG[number]['id']
export type PurchasableBillingPlan = Extract<BillingPlanId, 'PRO' | 'STUDIO'>
export const DEFAULT_BILLING_PLAN: BillingPlanId = 'PRO'
export function billingPlan(plan: string | null | undefined) {
return BILLING_PLAN_CATALOG.find((item) => item.id === plan) ?? null
}
export function planCreditLimit(plan: BillingPlanId) {
return billingPlan(plan)?.monthlyCreditLimit ?? 10_000
}

131
server/src/lib/billing.ts Normal file
View File

@@ -0,0 +1,131 @@
import { and, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { billingSubscriptions, episodes, generationJobs, renders, shots, usageLedger, workspaces } from '../db/schema.js'
import type { JobKind } from '../queue/types.js'
import { BILLING_PLAN_CATALOG } from './billing-plans.js'
import type { BillingProviderReadiness } from './stripe-billing.js'
export function billingPeriod(now = new Date()) {
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))
return { start, end }
}
function numberValue(value: string | number | null | undefined) {
const parsed = Number(value ?? 0)
return Number.isFinite(parsed) ? parsed : 0
}
export async function estimateJobCredits(type: JobKind, input: Record<string, unknown>) {
if (type === 'PIPELINE_HEALTHCHECK' || type === 'DELIVERY_PUBLISH') return 0
if (type === 'SCRIPT_GENERATE') return 30
if (type === 'STORYBOARD_GENERATE') return 40
if (type === 'ASSET_GENERATE') return 120
if (type === 'TTS_GENERATE') {
const text = typeof input.text === 'string' ? input.text : ''
return Math.max(1, Math.ceil(text.length / 5) * 0.6)
}
if (type === 'VIDEO_GENERATE') {
const duration = numberValue(typeof input.durationSeconds === 'number' || typeof input.durationSeconds === 'string' ? input.durationSeconds : 0) || 5
return Math.max(1, duration) * 35
}
if (type === 'LIPSYNC_GENERATE') return 60
if (type === 'EPISODE_RENDER' && typeof input.renderId === 'string') {
const [row] = await db.select({ episodeId: renders.episodeId, targetDurationSeconds: episodes.targetDurationSeconds }).from(renders)
.innerJoin(episodes, eq(episodes.id, renders.episodeId))
.where(eq(renders.id, input.renderId)).limit(1)
if (row) {
const durations = await db.select({ durationMs: shots.durationMs }).from(shots).where(eq(shots.episodeId, row.episodeId))
const seconds = durations.length ? durations.reduce((total, shot) => total + shot.durationMs, 0) / 1000 : row.targetDurationSeconds
return Math.max(1, seconds) * 0.8
}
}
return 100
}
export async function workspaceBillingSummary(workspaceId: string, options: {
now?: Date
providerReadiness: BillingProviderReadiness
canManage: boolean
}) {
const now = options.now ?? new Date()
const { start, end } = billingPeriod(now)
const [[workspace], [subscription]] = await Promise.all([
db.select({
id: workspaces.id,
plan: workspaces.plan,
monthlyCreditLimit: workspaces.monthlyCreditLimit,
}).from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1),
db.select().from(billingSubscriptions).where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1),
])
if (!workspace) return null
const [[usage], [reservation], breakdown, recent] = await Promise.all([
db.select({ credits: sql<string>`coalesce(sum(${usageLedger.credits}), 0)` }).from(usageLedger).where(and(
eq(usageLedger.workspaceId, workspaceId),
gte(usageLedger.occurredAt, start),
lt(usageLedger.occurredAt, end),
)),
db.select({ credits: sql<string>`coalesce(sum(${generationJobs.estimatedCredits}), 0)` }).from(generationJobs).where(and(
eq(generationJobs.workspaceId, workspaceId),
inArray(generationJobs.status, ['QUEUED', 'RUNNING']),
)),
db.select({
kind: usageLedger.kind,
quantity: sql<string>`coalesce(sum(${usageLedger.quantity}), 0)`,
credits: sql<string>`coalesce(sum(${usageLedger.credits}), 0)`,
}).from(usageLedger).where(and(
eq(usageLedger.workspaceId, workspaceId),
gte(usageLedger.occurredAt, start),
lt(usageLedger.occurredAt, end),
)).groupBy(usageLedger.kind).orderBy(desc(sql`sum(${usageLedger.credits})`)),
db.select().from(usageLedger).where(and(
eq(usageLedger.workspaceId, workspaceId),
gte(usageLedger.occurredAt, start),
lt(usageLedger.occurredAt, end),
)).orderBy(desc(usageLedger.occurredAt)).limit(50),
])
const usedCredits = numberValue(usage?.credits)
const reservedCredits = numberValue(reservation?.credits)
const remainingCredits = Math.max(0, workspace.monthlyCreditLimit - usedCredits - reservedCredits)
return {
plan: workspace.plan,
monthlyCreditLimit: workspace.monthlyCreditLimit,
usedCredits,
reservedCredits,
remainingCredits,
utilizationPercent: workspace.monthlyCreditLimit > 0 ? Math.min(100, (usedCredits + reservedCredits) / workspace.monthlyCreditLimit * 100) : 100,
canManage: options.canManage,
provider: {
name: options.providerReadiness.provider,
status: options.providerReadiness.status,
configured: Object.values(options.providerReadiness.plans).some((plan) => plan.checkoutAvailable),
missingRequirements: options.providerReadiness.missingRequirements,
portalAvailable: options.providerReadiness.portalConfigured && Boolean(subscription),
webhookConfigured: options.providerReadiness.webhookConfigured,
},
plans: BILLING_PLAN_CATALOG.map((plan) => ({
id: plan.id,
monthlyCreditLimit: plan.monthlyCreditLimit,
purchasable: plan.purchasable,
checkoutAvailable: plan.purchasable
? options.providerReadiness.plans[plan.id].checkoutAvailable
: false,
missingRequirements: plan.purchasable
? options.providerReadiness.plans[plan.id].missingRequirements
: [],
})),
subscription: subscription ? {
provider: subscription.provider,
status: subscription.status,
plan: subscription.plan,
hasSubscription: Boolean(subscription.subscriptionId),
currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
} : null,
period: { start: start.toISOString(), end: end.toISOString() },
breakdown: breakdown.map((row) => ({ kind: row.kind, quantity: numberValue(row.quantity), credits: numberValue(row.credits) })),
recent: recent.map((row) => ({ ...row, quantity: numberValue(row.quantity), credits: numberValue(row.credits), providerCostUsd: row.providerCostUsd === null ? null : numberValue(row.providerCostUsd) })),
}
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { applyCharacterConsistency, type AppliedCharacterProfile } from './character-consistency.js'
function profile(overrides: Partial<AppliedCharacterProfile> = {}): AppliedCharacterProfile {
return {
schemaVersion: 1,
assetId: crypto.randomUUID(),
assetName: '林遥',
referenceAssetVersionIds: [crypto.randomUUID()],
identityPrompt: '短发,琥珀色眼睛,面部轮廓保持一致',
costumePrompt: '深色风衣与银色胸针',
stylePrompt: '写实漫剧,冷色雨夜光线',
negativePrompt: '脸型漂移,服装变色',
consistencyStrength: 88,
identityLocked: true,
updatedAt: '2026-07-30T12:00:00.000Z',
updatedById: crypto.randomUUID(),
...overrides,
}
}
describe('character consistency application', () => {
it('prioritizes locked identity references and records an applied snapshot', () => {
const locked = profile()
const automaticReference = crypto.randomUUID()
const manualReference = crypto.randomUUID()
const result = applyCharacterConsistency({
prompt: '人物推开天台铁门',
negativePrompt: '多余手指',
params: { consistencyStrength: 55, seedMode: 'random' },
manualReferenceAssetVersionIds: [manualReference, locked.referenceAssetVersionIds[0]],
otherAutomaticReferenceAssetVersionIds: [automaticReference],
profiles: [locked],
})
expect(result.referenceAssetVersionIds).toEqual([
locked.referenceAssetVersionIds[0],
automaticReference,
manualReference,
])
expect(result.params).toMatchObject({ consistencyStrength: 88, seedMode: 'random' })
expect(result.prompt).toContain('角色「林遥」')
expect(result.prompt).toContain(locked.identityPrompt)
expect(result.prompt).toContain(locked.costumePrompt)
expect(result.negativePrompt).toBe('多余手指;脸型漂移,服装变色')
expect(result.profileSnapshots).toEqual([expect.objectContaining({
assetId: locked.assetId,
referenceAssetVersionIds: locked.referenceAssetVersionIds,
identityLocked: true,
})])
})
it('does not enforce an unlocked draft profile', () => {
const draft = profile({ identityLocked: false })
const result = applyCharacterConsistency({
prompt: '原始提示词',
negativePrompt: '',
params: { consistencyStrength: 64 },
manualReferenceAssetVersionIds: [],
profiles: [draft],
})
expect(result.prompt).toBe('原始提示词')
expect(result.params.consistencyStrength).toBe(64)
expect(result.referenceAssetVersionIds).toEqual([])
expect(result.profileSnapshots).toEqual([])
})
})

View File

@@ -0,0 +1,116 @@
import { z } from 'zod'
export const characterConsistencyProfileInputSchema = z.object({
referenceAssetVersionIds: z.array(z.uuid()).max(16).default([]),
identityPrompt: z.string().trim().max(4000).default(''),
costumePrompt: z.string().trim().max(4000).default(''),
stylePrompt: z.string().trim().max(4000).default(''),
negativePrompt: z.string().trim().max(4000).default(''),
consistencyStrength: z.number().int().min(50).max(100).default(82),
identityLocked: z.boolean().default(false),
}).superRefine((profile, context) => {
if (new Set(profile.referenceAssetVersionIds).size !== profile.referenceAssetVersionIds.length) {
context.addIssue({ code: 'custom', path: ['referenceAssetVersionIds'], message: 'Reference versions must be unique' })
}
if (profile.identityLocked && profile.referenceAssetVersionIds.length === 0) {
context.addIssue({ code: 'custom', path: ['referenceAssetVersionIds'], message: 'A locked identity requires at least one reference version' })
}
})
export type CharacterConsistencyProfileInput = z.infer<typeof characterConsistencyProfileInputSchema>
export const characterConsistencyProfileSchema = characterConsistencyProfileInputSchema.and(z.object({
schemaVersion: z.literal(1),
updatedAt: z.iso.datetime({ offset: true }),
updatedById: z.uuid(),
}))
export type CharacterConsistencyProfile = z.infer<typeof characterConsistencyProfileSchema>
export interface AppliedCharacterProfile extends CharacterConsistencyProfile {
assetId: string
assetName: string
}
export function readCharacterConsistencyProfile(metadata: Record<string, unknown> | null | undefined) {
const parsed = characterConsistencyProfileSchema.safeParse(metadata?.consistencyProfile)
return parsed.success ? parsed.data : null
}
export function createCharacterConsistencyProfile(
input: CharacterConsistencyProfileInput,
updatedById: string,
updatedAt = new Date(),
): CharacterConsistencyProfile {
return {
...input,
schemaVersion: 1,
updatedAt: updatedAt.toISOString(),
updatedById,
}
}
function appendPrompt(base: string, profiles: AppliedCharacterProfile[]) {
const blocks = profiles.map((profile) => {
const fields = [
profile.identityPrompt ? `身份特征:${profile.identityPrompt}` : '',
profile.costumePrompt ? `服装约束:${profile.costumePrompt}` : '',
profile.stylePrompt ? `视觉风格:${profile.stylePrompt}` : '',
].filter(Boolean)
return fields.length > 0 ? `角色「${profile.assetName}」\n${fields.join('\n')}` : ''
}).filter(Boolean)
return blocks.length > 0 ? [base, '角色一致性约束:', ...blocks].join('\n') : base
}
function mergeNegativePrompt(base: string, profiles: AppliedCharacterProfile[]) {
return [...new Set([base.trim(), ...profiles.map((profile) => profile.negativePrompt.trim())].filter(Boolean))].join(';')
}
export function applyCharacterConsistency(input: {
prompt: string
negativePrompt: string
params: Record<string, unknown>
manualReferenceAssetVersionIds: string[]
profiles: AppliedCharacterProfile[]
otherAutomaticReferenceAssetVersionIds?: string[]
}) {
const lockedProfiles = input.profiles.filter((profile) => profile.identityLocked)
const lockedReferenceIds = [...new Set(lockedProfiles.flatMap((profile) => profile.referenceAssetVersionIds))]
if (lockedReferenceIds.length > 16) {
throw new Error('Locked character profiles contain more than 16 unique identity references')
}
const referenceAssetVersionIds = [...new Set([
...lockedReferenceIds,
...(input.otherAutomaticReferenceAssetVersionIds ?? []),
...input.manualReferenceAssetVersionIds,
])].slice(0, 16)
const consistencyStrength = lockedProfiles.length > 0
? Math.max(...lockedProfiles.map((profile) => profile.consistencyStrength))
: undefined
const params = {
...input.params,
...(consistencyStrength === undefined ? {} : { consistencyStrength }),
}
const profileSnapshots = lockedProfiles.map((profile) => ({
schemaVersion: profile.schemaVersion,
assetId: profile.assetId,
assetName: profile.assetName,
referenceAssetVersionIds: [...profile.referenceAssetVersionIds],
identityPrompt: profile.identityPrompt,
costumePrompt: profile.costumePrompt,
stylePrompt: profile.stylePrompt,
negativePrompt: profile.negativePrompt,
consistencyStrength: profile.consistencyStrength,
identityLocked: true as const,
updatedAt: profile.updatedAt,
updatedById: profile.updatedById,
}))
return {
prompt: appendPrompt(input.prompt, lockedProfiles),
negativePrompt: mergeNegativePrompt(input.negativePrompt, lockedProfiles),
params,
referenceAssetVersionIds,
profileSnapshots,
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { decryptCredential, encryptCredential } from './credential-vault.js'
const secret = 'vault-test-secret-with-at-least-32-characters'
describe('credential vault', () => {
it('encrypts credentials with randomized authenticated ciphertext', () => {
const first = encryptCredential('provider-access-token', secret)
const second = encryptCredential('provider-access-token', secret)
expect(first).not.toBe(second)
expect(first).not.toContain('provider-access-token')
expect(decryptCredential(first, secret)).toBe('provider-access-token')
expect(decryptCredential(second, secret)).toBe('provider-access-token')
})
it('rejects tampering, wrong keys, and unsupported formats', () => {
const encrypted = encryptCredential('refresh-token', secret)
const parts = encrypted.split('.')
const ciphertext = Buffer.from(parts[3]!, 'base64url')
ciphertext[0] ^= 1
parts[3] = ciphertext.toString('base64url')
expect(() => decryptCredential(parts.join('.'), secret)).toThrow('could not be decrypted')
expect(() => decryptCredential(encrypted, 'different-secret-with-at-least-32-characters')).toThrow('could not be decrypted')
expect(() => decryptCredential('plaintext-token', secret)).toThrow('unsupported format')
})
it('rejects non-canonical base64url components even when they decode to the same bytes', () => {
const encrypted = encryptCredential('refresh-token', secret)
const parts = encrypted.split('.')
const encoded = parts[3]!
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
const lastIndex = alphabet.indexOf(encoded.at(-1)!)
const alternateIndex = (lastIndex & 0b110000) | ((lastIndex + 1) & 0b001111)
const nonCanonical = `${encoded.slice(0, -1)}${alphabet[alternateIndex]}`
expect(Buffer.from(nonCanonical, 'base64url')).toEqual(Buffer.from(encoded, 'base64url'))
parts[3] = nonCanonical
expect(() => decryptCredential(parts.join('.'), secret)).toThrow('could not be decrypted')
})
})

View File

@@ -0,0 +1,51 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import { config } from '../config.js'
const VERSION = 'v1'
const ALGORITHM = 'aes-256-gcm'
function encryptionKey(secret: string) {
return createHash('sha256').update(secret, 'utf8').digest()
}
function decodeBase64Url(value: string, expectedLength?: number) {
if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('invalid base64url')
const decoded = Buffer.from(value, 'base64url')
if (
decoded.length === 0
|| decoded.toString('base64url') !== value
|| (expectedLength !== undefined && decoded.length !== expectedLength)
) {
throw new Error('invalid base64url')
}
return decoded
}
export function encryptCredential(value: string, secret = config.CREDENTIAL_ENCRYPTION_KEY) {
if (!value) throw new Error('Credential value cannot be empty')
const iv = randomBytes(12)
const cipher = createCipheriv(ALGORITHM, encryptionKey(secret), iv)
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [VERSION, iv.toString('base64url'), tag.toString('base64url'), encrypted.toString('base64url')].join('.')
}
export function decryptCredential(value: string, secret = config.CREDENTIAL_ENCRYPTION_KEY) {
const [version, ivValue, tagValue, encryptedValue, extra] = value.split('.')
if (version !== VERSION || !ivValue || !tagValue || !encryptedValue || extra) {
throw new Error('Stored credential has an unsupported format')
}
try {
const iv = decodeBase64Url(ivValue, 12)
const tag = decodeBase64Url(tagValue, 16)
const encrypted = decodeBase64Url(encryptedValue)
const decipher = createDecipheriv(ALGORITHM, encryptionKey(secret), iv)
decipher.setAuthTag(tag)
return Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]).toString('utf8')
} catch {
throw new Error('Stored credential could not be decrypted')
}
}

24
server/src/lib/errors.ts Normal file
View File

@@ -0,0 +1,24 @@
export class AppError extends Error {
readonly statusCode: number
readonly code: string
readonly details?: unknown
constructor(statusCode: number, code: string, message: string, details?: unknown) {
super(message)
this.name = 'AppError'
this.statusCode = statusCode
this.code = code
this.details = details
}
}
export const errors = {
badRequest: (message: string, details?: unknown) => new AppError(400, 'BAD_REQUEST', message, details),
unauthorized: (message = 'Authentication required') => new AppError(401, 'UNAUTHORIZED', message),
emailVerificationRequired: () => new AppError(403, 'EMAIL_VERIFICATION_REQUIRED', '请先完成邮箱验证'),
forbidden: (message = 'Insufficient permissions') => new AppError(403, 'FORBIDDEN', message),
notFound: (resource = 'Resource') => new AppError(404, 'NOT_FOUND', `${resource} not found`),
conflict: (message: string) => new AppError(409, 'CONFLICT', message),
serviceUnavailable: (message: string, details?: unknown) => new AppError(503, 'SERVICE_UNAVAILABLE', message, details),
creditLimitExceeded: (details?: unknown) => new AppError(402, 'CREDIT_LIMIT_EXCEEDED', 'Workspace credit limit would be exceeded', details),
}

20
server/src/lib/http.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { FastifyRequest } from 'fastify'
import { type ZodType, ZodError } from 'zod'
import { errors } from './errors.js'
export function parseWith<T>(schema: ZodType<T>, value: unknown): T {
try {
return schema.parse(value)
} catch (error) {
if (error instanceof ZodError) {
throw errors.badRequest('Request validation failed', error.issues)
}
throw error
}
}
export function requestMeta(request: FastifyRequest) {
return {
ipAddress: request.ip,
userAgent: request.headers['user-agent'] ?? null,
}
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
import { sendWorkspaceInvitationEmail, workspaceInvitationUrl } from './mailer.js'
describe('workspace invitation email', () => {
it('builds an invitation URL on the configured public origin', () => {
const url = new URL(workspaceInvitationUrl('invitation-token'))
expect(url.searchParams.get('invite')).toBe('invitation-token')
expect([...url.searchParams.keys()]).toEqual(['invite'])
expect(url.hash).toBe('')
})
it('sends escaped HTML and a complete plain-text fallback through Resend', async () => {
let requestBody: Record<string, unknown> = {}
const fetchMock = vi.fn<typeof fetch>(async (_input, init) => {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(JSON.stringify({ id: 'email_123' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
const delivery = await sendWorkspaceInvitationEmail({
to: 'invitee@example.com',
inviterName: '<导演>',
workspaceName: '北岸 & 工作室',
roleLabel: '编辑',
expiresAt: new Date('2026-08-08T12:00:00.000Z'),
url: 'https://studio.example.com/?invite=token&source=email',
}, {
apiKey: 're_test_key',
from: 'FrameFlow <team@example.com>',
fetchImpl: fetchMock,
})
expect(delivery).toEqual({ sent: true, provider: 'resend', id: 'email_123' })
expect(fetchMock).toHaveBeenCalledOnce()
expect(requestBody).toMatchObject({
from: 'FrameFlow <team@example.com>',
to: ['invitee@example.com'],
subject: '<导演> 邀请你加入 北岸 & 工作室',
})
expect(String(requestBody.text)).toContain('https://studio.example.com/?invite=token&source=email')
expect(String(requestBody.html)).toContain('&lt;导演&gt;')
expect(String(requestBody.html)).toContain('北岸 &amp; 工作室')
expect(String(requestBody.html)).toContain('invite=token&amp;source=email')
expect(String(requestBody.html)).not.toContain('<导演>')
})
it('reports a development fallback without making a network call', async () => {
const fetchMock = vi.fn<typeof fetch>()
const delivery = await sendWorkspaceInvitationEmail({
to: 'invitee@example.com',
inviterName: '导演',
workspaceName: '北岸工作室',
roleLabel: '编辑',
expiresAt: new Date('2026-08-08T12:00:00.000Z'),
url: 'http://127.0.0.1:4173/?invite=token',
}, { apiKey: '', fetchImpl: fetchMock })
expect(delivery).toEqual({ sent: false, provider: 'development' })
expect(fetchMock).not.toHaveBeenCalled()
})
})

128
server/src/lib/mailer.ts Normal file
View File

@@ -0,0 +1,128 @@
import { config } from '../config.js'
import type { AccountTokenType } from './account-tokens.js'
export interface EmailTransportOptions {
apiKey?: string
from?: string
fetchImpl?: typeof fetch
}
export interface WorkspaceInvitationEmailInput {
to: string
inviterName: string
workspaceName: string
roleLabel: string
expiresAt: Date
url: string
}
export interface InvitationMailer {
send(input: WorkspaceInvitationEmailInput): Promise<EmailDeliveryResult>
}
export type EmailDeliveryResult =
| { sent: false; provider: 'development' }
| { sent: true; provider: 'resend'; id: string | null }
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character]!)
}
export function accountActionUrl(type: AccountTokenType, token: string) {
const origin = config.WEB_ORIGIN.split(',')[0]?.trim() || 'http://127.0.0.1:4173'
const url = new URL(origin)
url.search = ''
url.hash = ''
url.searchParams.set(type === 'EMAIL_VERIFICATION' ? 'verify_email' : 'reset_password', token)
return url.toString()
}
export function workspaceInvitationUrl(token: string) {
const origin = config.WEB_ORIGIN.split(',')[0]?.trim() || 'http://127.0.0.1:4173'
const url = new URL(origin)
url.search = ''
url.hash = ''
url.searchParams.set('invite', token)
return url.toString()
}
async function sendEmail(input: {
to: string
subject: string
text: string
html: string
}, options: EmailTransportOptions = {}): Promise<EmailDeliveryResult> {
const apiKey = options.apiKey ?? config.RESEND_API_KEY
if (!apiKey) return { sent: false, provider: 'development' }
const response = await (options.fetchImpl ?? fetch)('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: options.from ?? config.EMAIL_FROM,
to: [input.to],
subject: input.subject,
text: input.text,
html: input.html,
}),
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) throw new Error(`Email provider returned ${response.status}: ${(await response.text()).slice(0, 500)}`)
const body = await response.json() as { id?: string }
return { sent: true, provider: 'resend', id: body.id ?? null }
}
export async function sendAccountActionEmail(input: {
type: AccountTokenType
to: string
displayName: string
url: string
}, options: EmailTransportOptions = {}) {
const verification = input.type === 'EMAIL_VERIFICATION'
const title = verification ? '验证你的 FrameFlow 邮箱' : '重置你的 FrameFlow 密码'
const action = verification ? '验证邮箱' : '重置密码'
const lifetime = verification
? `${config.EMAIL_VERIFICATION_TTL_HOURS} 小时`
: `${config.PASSWORD_RESET_TTL_MINUTES} 分钟`
const safeName = escapeHtml(input.displayName)
const safeUrl = escapeHtml(input.url)
return sendEmail({
to: input.to,
subject: title,
text: `${input.displayName},请在 ${lifetime}内打开以下链接完成${action}:\n\n${input.url}\n\n如果不是你发起的操作,请忽略此邮件。`,
html: `<p>${safeName},你好。</p><p>请在 ${lifetime}内完成${action}。</p><p><a href="${safeUrl}">${action}</a></p><p>如果不是你发起的操作,请忽略此邮件。</p>`,
}, options)
}
export async function sendWorkspaceInvitationEmail(
input: WorkspaceInvitationEmailInput,
options: EmailTransportOptions = {},
) {
const expiresAt = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'long',
timeStyle: 'short',
}).format(input.expiresAt)
const safeInviterName = escapeHtml(input.inviterName)
const safeWorkspaceName = escapeHtml(input.workspaceName)
const safeRoleLabel = escapeHtml(input.roleLabel)
const safeUrl = escapeHtml(input.url)
return sendEmail({
to: input.to,
subject: `${input.inviterName} 邀请你加入 ${input.workspaceName}`,
text: `${input.inviterName} 邀请你加入 FrameFlow 工作室「${input.workspaceName}」,初始角色为${input.roleLabel}。\n\n请在 ${expiresAt} 前打开以下链接接受邀请:\n\n${input.url}\n\n该链接只能由收到邀请的邮箱账号使用。如果你不认识邀请人,请忽略此邮件。`,
html: `<p>${safeInviterName} 邀请你加入 FrameFlow 工作室「${safeWorkspaceName}」。</p><p>初始角色:<strong>${safeRoleLabel}</strong></p><p>请在 ${escapeHtml(expiresAt)} 前接受邀请。</p><p><a href="${safeUrl}">加入工作室</a></p><p>该链接只能由收到邀请的邮箱账号使用。如果你不认识邀请人,请忽略此邮件。</p>`,
}, options)
}
export const resendInvitationMailer: InvitationMailer = {
send: sendWorkspaceInvitationEmail,
}

62
server/src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,62 @@
import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from 'prom-client'
export const metricsRegistry = new Registry()
collectDefaultMetrics({ register: metricsRegistry, prefix: 'frameflow_' })
export const apiRequestsTotal = new Counter({
name: 'frameflow_api_requests_total',
help: 'Total API requests',
labelNames: ['method', 'route', 'status'] as const,
registers: [metricsRegistry],
})
export const apiRequestDurationSeconds = new Histogram({
name: 'frameflow_api_request_duration_seconds',
help: 'API request duration in seconds',
labelNames: ['method', 'route', 'status'] as const,
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [metricsRegistry],
})
export const applicationDependencyAvailable = new Gauge({
name: 'frameflow_application_dependency_available',
help: 'Whether an API readiness dependency is currently available (1 or 0)',
labelNames: ['dependency'] as const,
registers: [metricsRegistry],
})
export const generationJobsProcessedTotal = new Counter({
name: 'frameflow_generation_jobs_processed_total',
help: 'Generation job attempts by result',
labelNames: ['type', 'result'] as const,
registers: [metricsRegistry],
})
export const generationJobDurationSeconds = new Histogram({
name: 'frameflow_generation_job_duration_seconds',
help: 'Generation job attempt duration in seconds',
labelNames: ['type'] as const,
buckets: [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600],
registers: [metricsRegistry],
})
export const generationWorkerActiveJobs = new Gauge({
name: 'frameflow_generation_worker_active_jobs',
help: 'Generation jobs currently handled by this worker',
registers: [metricsRegistry],
})
export const generationWorkerHeartbeatTimestampSeconds = new Gauge({
name: 'frameflow_generation_worker_heartbeat_timestamp_seconds',
help: 'Unix timestamp of the last successful worker heartbeat write',
registers: [metricsRegistry],
})
export const generationJobQueueDelaySeconds = new Histogram({
name: 'frameflow_generation_job_queue_delay_seconds',
help: 'Time from queue submission until a worker starts the job',
labelNames: ['type'] as const,
buckets: [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1800],
registers: [metricsRegistry],
})

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { buildJobTerminalMessage } from './notifications.js'
describe('job terminal notification copy', () => {
it('describes an asynchronous platform delivery as submitted', () => {
expect(buildJobTerminalMessage({
jobType: 'DELIVERY_PUBLISH',
status: 'SUCCEEDED',
output: { status: 'PUBLISHING' },
})).toEqual({
title: '平台发布已提交',
body: '平台已接收发布任务,正在处理中。最终结果会在发布管理中更新。',
})
})
it('describes a terminal delivery as completed', () => {
expect(buildJobTerminalMessage({
jobType: 'DELIVERY_PUBLISH',
status: 'SUCCEEDED',
output: { status: 'PUBLISHED' },
})).toEqual({
title: '成片交付已完成',
body: '成片已完成交付,可前往发布管理查看详情。',
})
})
it('keeps ordinary job and failure messages unchanged', () => {
expect(buildJobTerminalMessage({ jobType: 'SCRIPT_GENERATE', status: 'SUCCEEDED' }).title).toBe('剧本生成已完成')
expect(buildJobTerminalMessage({
jobType: 'VIDEO_GENERATE',
status: 'FAILED',
errorMessage: '服务商超时',
})).toEqual({ title: '视频生成失败', body: '服务商超时' })
})
})

View File

@@ -0,0 +1,240 @@
import { and, eq, inArray } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberships, notifications } from '../db/schema.js'
import type { Notification } from '../db/schema.js'
import { publishRealtimeEvent } from './realtime.js'
type NotificationTone = Notification['tone']
export interface CreateNotificationInput {
workspaceId: string
userId: string
kind: string
tone: NotificationTone
title: string
body?: string
page: string
resourceType?: string
resourceId?: string
dedupeKey: string
metadata?: Record<string, unknown>
}
export async function createNotifications(inputs: CreateNotificationInput[]) {
if (inputs.length === 0) return []
const created = await db.insert(notifications).values(inputs.map((input) => ({
workspaceId: input.workspaceId,
userId: input.userId,
kind: input.kind,
tone: input.tone,
title: input.title.slice(0, 240),
body: (input.body ?? '').slice(0, 10_000),
page: input.page,
resourceType: input.resourceType,
resourceId: input.resourceId,
dedupeKey: input.dedupeKey,
metadata: input.metadata ?? {},
}))).onConflictDoNothing().returning()
await Promise.all(created.map((notification) => publishRealtimeEvent({
workspaceId: notification.workspaceId,
userId: notification.userId,
type: 'notification.created',
resourceType: 'notification',
resourceId: notification.id,
payload: { notification },
})))
return created
}
export async function createNotification(input: CreateNotificationInput) {
const [created] = await createNotifications([input])
return created ?? null
}
const jobLabels: Record<string, string> = {
SCRIPT_GENERATE: '剧本生成',
STORYBOARD_GENERATE: '智能分镜',
ASSET_GENERATE: '画面生成',
TTS_GENERATE: '配音生成',
VIDEO_GENERATE: '视频生成',
LIPSYNC_GENERATE: '口型同步',
EPISODE_RENDER: '成片渲染',
DELIVERY_PUBLISH: '成片交付',
}
interface JobTerminalInput {
workspaceId: string
userId: string
jobId: string
jobType: string
status: 'SUCCEEDED' | 'FAILED'
errorMessage?: string
projectId?: string
episodeId?: string
output?: Record<string, unknown>
}
export function buildJobTerminalMessage(input: Pick<JobTerminalInput, 'jobType' | 'status' | 'errorMessage' | 'output'>) {
const succeeded = input.status === 'SUCCEEDED'
const label = jobLabels[input.jobType] ?? input.jobType
if (!succeeded) {
return {
title: `${label}失败`,
body: input.errorMessage || '任务执行失败,可前往任务中心重试。',
}
}
if (input.jobType === 'DELIVERY_PUBLISH' && input.output?.status === 'PUBLISHING') {
return {
title: '平台发布已提交',
body: '平台已接收发布任务,正在处理中。最终结果会在发布管理中更新。',
}
}
if (input.jobType === 'DELIVERY_PUBLISH' && input.output?.status === 'PUBLISHED') {
return {
title: '成片交付已完成',
body: '成片已完成交付,可前往发布管理查看详情。',
}
}
return {
title: `${label}已完成`,
body: '产出已保存,可以继续下一步制作。',
}
}
export async function notifyJobTerminal(input: JobTerminalInput) {
if (input.jobType === 'PIPELINE_HEALTHCHECK') return null
const succeeded = input.status === 'SUCCEEDED'
const page = input.jobType === 'DELIVERY_PUBLISH' ? 'publish' : 'jobs'
const message = buildJobTerminalMessage(input)
return createNotification({
workspaceId: input.workspaceId,
userId: input.userId,
kind: succeeded ? 'JOB_SUCCEEDED' : 'JOB_FAILED',
tone: succeeded ? 'SUCCESS' : 'ERROR',
title: message.title,
body: message.body,
page,
resourceType: 'generation_job',
resourceId: input.jobId,
dedupeKey: `job:${input.jobId}:${input.status}`,
metadata: {
jobType: input.jobType,
status: input.status,
deliveryStatus: typeof input.output?.status === 'string' ? input.output.status : undefined,
projectId: input.projectId,
episodeId: input.episodeId,
},
})
}
export async function reviewRecipientIds(workspaceId: string, assignedToId?: string | null) {
if (assignedToId) return [assignedToId]
const rows = await db.select({ userId: memberships.userId }).from(memberships)
.where(and(
eq(memberships.workspaceId, workspaceId),
inArray(memberships.role, ['OWNER', 'ADMIN', 'REVIEWER']),
))
return rows.map((row) => row.userId)
}
export async function notifyReviewRequested(input: {
workspaceId: string
reviewId: string
requestedById: string
assignedToId?: string | null
summary?: string
targetType: string
}) {
const recipients = (await reviewRecipientIds(input.workspaceId, input.assignedToId))
.filter((userId) => userId !== input.requestedById)
return createNotifications(recipients.map((userId) => ({
workspaceId: input.workspaceId,
userId,
kind: 'REVIEW_REQUESTED',
tone: 'WARNING',
title: '收到新的审核任务',
body: input.summary || '有一个制作版本等待审核。',
page: 'reviews',
resourceType: 'review_request',
resourceId: input.reviewId,
dedupeKey: `review:${input.reviewId}:requested`,
metadata: { targetType: input.targetType },
})))
}
export async function notifyReviewAssigned(input: {
workspaceId: string
reviewId: string
assignedToId: string
assignedById: string
summary?: string
}) {
if (input.assignedToId === input.assignedById) return null
return createNotification({
workspaceId: input.workspaceId,
userId: input.assignedToId,
kind: 'REVIEW_ASSIGNED',
tone: 'WARNING',
title: '审核任务已指派给你',
body: input.summary || '请前往审核中心处理。',
page: 'reviews',
resourceType: 'review_request',
resourceId: input.reviewId,
dedupeKey: `review:${input.reviewId}:assigned:${input.assignedToId}`,
})
}
export async function notifyReviewComment(input: {
workspaceId: string
reviewId: string
authorId: string
requestedById: string
assignedToId?: string | null
body: string
commentId: string
}) {
const recipients = [...new Set([input.requestedById, input.assignedToId].filter((value): value is string => Boolean(value)))]
.filter((userId) => userId !== input.authorId)
return createNotifications(recipients.map((userId) => ({
workspaceId: input.workspaceId,
userId,
kind: 'REVIEW_COMMENTED',
tone: 'INFO',
title: '审核收到新意见',
body: input.body,
page: 'reviews',
resourceType: 'review_request',
resourceId: input.reviewId,
dedupeKey: `review-comment:${input.commentId}:${userId}`,
})))
}
export async function notifyReviewResolved(input: {
workspaceId: string
reviewId: string
requestedById: string
resolvedById: string
status: 'APPROVED' | 'CHANGES_REQUESTED' | 'CANCELLED'
summary?: string
}) {
if (input.requestedById === input.resolvedById) return null
const statusCopy = {
APPROVED: { title: '审核已通过', tone: 'SUCCESS' as const },
CHANGES_REQUESTED: { title: '审核已退回修改', tone: 'WARNING' as const },
CANCELLED: { title: '审核已取消', tone: 'INFO' as const },
}[input.status]
return createNotification({
workspaceId: input.workspaceId,
userId: input.requestedById,
kind: 'REVIEW_RESOLVED',
tone: statusCopy.tone,
title: statusCopy.title,
body: input.summary || '审核状态已经更新。',
page: 'reviews',
resourceType: 'review_request',
resourceId: input.reviewId,
dedupeKey: `review:${input.reviewId}:resolved:${input.status}`,
metadata: { status: input.status },
})
}

View File

@@ -0,0 +1,51 @@
import type { FastifyRequest } from 'fastify'
import { and, eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { memberships, projects } from '../db/schema.js'
import { errors } from './errors.js'
export type WorkspaceRole = 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER'
export type Permission =
| 'workspace:manage'
| 'members:manage'
| 'project:read'
| 'project:write'
| 'content:write'
| 'review:decide'
| 'render:create'
| 'publish:create'
| 'publishing:manage'
| 'billing:read'
| 'billing:manage'
| 'services:verify'
| 'audit:read'
const permissions: Record<WorkspaceRole, ReadonlySet<Permission>> = {
OWNER: new Set(['workspace:manage', 'members:manage', 'project:read', 'project:write', 'content:write', 'review:decide', 'render:create', 'publish:create', 'publishing:manage', 'billing:read', 'billing:manage', 'services:verify', 'audit:read']),
ADMIN: new Set(['workspace:manage', 'members:manage', 'project:read', 'project:write', 'content:write', 'review:decide', 'render:create', 'publish:create', 'publishing:manage', 'billing:read', 'billing:manage', 'services:verify', 'audit:read']),
EDITOR: new Set(['project:read', 'project:write', 'content:write', 'render:create']),
REVIEWER: new Set(['project:read', 'review:decide']),
VIEWER: new Set(['project:read']),
}
export function can(role: WorkspaceRole, permission: Permission) {
return permissions[role].has(permission)
}
export async function requireWorkspacePermission(request: FastifyRequest, workspaceId: string, permission: Permission) {
const [membership] = await db.select({ role: memberships.role }).from(memberships).where(and(
eq(memberships.workspaceId, workspaceId),
eq(memberships.userId, request.user.sub),
)).limit(1)
if (!membership) throw errors.forbidden('You are not a member of this workspace')
if (!can(membership.role, permission)) throw errors.forbidden()
return membership
}
export async function requireProjectPermission(request: FastifyRequest, projectId: string, permission: Permission) {
const [project] = await db.select({ id: projects.id, workspaceId: projects.workspaceId }).from(projects).where(eq(projects.id, projectId)).limit(1)
if (!project) throw errors.notFound('Project')
const membership = await requireWorkspacePermission(request, project.workspaceId, permission)
return { project, membership }
}

View File

@@ -0,0 +1,90 @@
import { randomUUID } from 'node:crypto'
import type { Redis } from 'ioredis'
import { createRedisConnection, redis } from '../queue/connection.js'
export const REALTIME_CHANNEL = 'frameflow:realtime:v1'
export interface RealtimeEvent {
id: string
workspaceId: string
userId?: string
type: string
resourceType?: string
resourceId?: string
occurredAt: string
payload: Record<string, unknown>
}
export type RealtimeEventInput = Omit<RealtimeEvent, 'id' | 'occurredAt'> & {
id?: string
occurredAt?: string
}
export function normalizeRealtimeEvent(input: RealtimeEventInput): RealtimeEvent {
return {
id: input.id ?? randomUUID(),
workspaceId: input.workspaceId,
...(input.userId ? { userId: input.userId } : {}),
type: input.type,
...(input.resourceType ? { resourceType: input.resourceType } : {}),
...(input.resourceId ? { resourceId: input.resourceId } : {}),
occurredAt: input.occurredAt ?? new Date().toISOString(),
payload: input.payload,
}
}
export async function publishRealtimeEvent(input: RealtimeEventInput) {
const event = normalizeRealtimeEvent(input)
try {
await redis.publish(REALTIME_CHANNEL, JSON.stringify(event))
return true
} catch {
return false
}
}
interface RealtimeClient {
workspaceId: string
userId: string
send: (event: RealtimeEvent) => void
close: () => void
}
export class RealtimeHub {
private readonly subscriber: Redis
private readonly clients = new Set<RealtimeClient>()
private readonly ready: Promise<unknown>
constructor(onError: (error: Error) => void = () => {}) {
this.subscriber = createRedisConnection()
this.subscriber.on('error', onError)
this.subscriber.on('message', (_channel, message) => {
let event: RealtimeEvent
try {
event = JSON.parse(message) as RealtimeEvent
} catch {
return
}
if (!event.workspaceId || !event.type || !event.id) return
for (const client of this.clients) {
if (client.workspaceId !== event.workspaceId) continue
if (event.userId && client.userId !== event.userId) continue
client.send(event)
}
})
this.ready = this.subscriber.subscribe(REALTIME_CHANNEL)
}
async addClient(client: RealtimeClient) {
await this.ready
this.clients.add(client)
return () => this.clients.delete(client)
}
async close() {
for (const client of this.clients) client.close()
this.clients.clear()
await this.ready.catch(() => undefined)
await this.subscriber.quit().catch(() => undefined)
}
}

Some files were not shown because too many files have changed in this diff Show More