Release v1.0.0
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
95
.env.example
Normal 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
108
.env.production.example
Normal 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
176
.github/workflows/ci.yml
vendored
Normal 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
12
.gitignore
vendored
Normal 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
48
Dockerfile.server
Normal 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
23
Dockerfile.web
Normal 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
37
README.md
Normal 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.
|
||||
22
deploy/alertmanager.yml.template
Normal file
22
deploy/alertmanager.yml.template
Normal 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
156
deploy/alerts.yml
Normal 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.
|
||||
83
deploy/nginx/default.conf.template
Normal file
83
deploy/nginx/default.conf.template
Normal 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
32
deploy/prometheus.yml
Normal 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"]
|
||||
9
deploy/systemd/frameflow-backup.env.example
Normal file
9
deploy/systemd/frameflow-backup.env.example
Normal 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
|
||||
35
deploy/systemd/frameflow-backup.service
Normal file
35
deploy/systemd/frameflow-backup.service
Normal 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
|
||||
12
deploy/systemd/frameflow-backup.timer
Normal file
12
deploy/systemd/frameflow-backup.timer
Normal 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
|
||||
36
deploy/systemd/frameflow-restore-rehearsal.service
Normal file
36
deploy/systemd/frameflow-restore-rehearsal.service
Normal 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
|
||||
12
deploy/systemd/frameflow-restore-rehearsal.timer
Normal file
12
deploy/systemd/frameflow-restore-rehearsal.timer
Normal 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
324
docker-compose.prod.yml
Normal 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
69
docker-compose.yml
Normal 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
350
docs/deployment.md
Normal 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
14
index.html
Normal 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
6502
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
public/assets/character-linyao.jpg
Normal file
BIN
public/assets/character-linyao.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
BIN
public/assets/character-lucheng.jpg
Normal file
BIN
public/assets/character-lucheng.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 398 KiB |
BIN
public/assets/character-suyue.jpg
Normal file
BIN
public/assets/character-suyue.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
BIN
public/assets/scene-city.jpg
Normal file
BIN
public/assets/scene-city.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 582 KiB |
BIN
public/assets/scene-rooftop.jpg
Normal file
BIN
public/assets/scene-rooftop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
BIN
public/assets/scene-studio.jpg
Normal file
BIN
public/assets/scene-studio.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
310
scripts/alertmanager-smoke.mjs
Executable file
310
scripts/alertmanager-smoke.mjs
Executable 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
103
scripts/backup.sh
Executable 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
360
scripts/rehearse-restore.sh
Executable 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"
|
||||
76
scripts/scheduled-maintenance.sh
Executable file
76
scripts/scheduled-maintenance.sh
Executable 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
927
scripts/smoke-test.mjs
Executable 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
305
scripts/stripe-smoke.mjs
Executable 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
98
scripts/verify-backup.sh
Executable 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
12
server/drizzle.config.ts
Normal 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,
|
||||
})
|
||||
356
server/drizzle/0000_last_spectrum.sql
Normal file
356
server/drizzle/0000_last_spectrum.sql
Normal 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");
|
||||
1
server/drizzle/0001_perfect_gravity.sql
Normal file
1
server/drizzle/0001_perfect_gravity.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."job_type" ADD VALUE 'PIPELINE_HEALTHCHECK' BEFORE 'SCRIPT_GENERATE';
|
||||
1
server/drizzle/0002_yielding_spencer_smythe.sql
Normal file
1
server/drizzle/0002_yielding_spencer_smythe.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."asset_type" ADD VALUE 'SHOT';
|
||||
1
server/drizzle/0003_hard_norrin_radd.sql
Normal file
1
server/drizzle/0003_hard_norrin_radd.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."job_type" ADD VALUE 'STORYBOARD_GENERATE' BEFORE 'ASSET_GENERATE';
|
||||
1
server/drizzle/0004_goofy_karnak.sql
Normal file
1
server/drizzle/0004_goofy_karnak.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "generation_jobs" ADD COLUMN "estimated_credits" numeric(20, 4) DEFAULT '0' NOT NULL;
|
||||
21
server/drizzle/0005_brave_betty_ross.sql
Normal file
21
server/drizzle/0005_brave_betty_ross.sql
Normal 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");
|
||||
24
server/drizzle/0006_wooden_texas_twister.sql
Normal file
24
server/drizzle/0006_wooden_texas_twister.sql
Normal 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");
|
||||
15
server/drizzle/0007_sudden_roland_deschain.sql
Normal file
15
server/drizzle/0007_sudden_roland_deschain.sql
Normal 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");
|
||||
4
server/drizzle/0008_red_trish_tilby.sql
Normal file
4
server/drizzle/0008_red_trish_tilby.sql
Normal 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");
|
||||
34
server/drizzle/0009_chilly_mockingbird.sql
Normal file
34
server/drizzle/0009_chilly_mockingbird.sql
Normal 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");
|
||||
3
server/drizzle/0010_sharp_odin.sql
Normal file
3
server/drizzle/0010_sharp_odin.sql
Normal 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;
|
||||
25
server/drizzle/0011_futuristic_stature.sql
Normal file
25
server/drizzle/0011_futuristic_stature.sql
Normal 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");
|
||||
62
server/drizzle/0012_warm_old_lace.sql
Normal file
62
server/drizzle/0012_warm_old_lace.sql
Normal 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");
|
||||
3
server/drizzle/0013_lethal_giant_man.sql
Normal file
3
server/drizzle/0013_lethal_giant_man.sql
Normal 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");
|
||||
3
server/drizzle/0014_heavy_retro_girl.sql
Normal file
3
server/drizzle/0014_heavy_retro_girl.sql
Normal 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;
|
||||
3
server/drizzle/0015_reflective_true_believers.sql
Normal file
3
server/drizzle/0015_reflective_true_believers.sql
Normal 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;
|
||||
20
server/drizzle/0016_amused_longshot.sql
Normal file
20
server/drizzle/0016_amused_longshot.sql
Normal 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");
|
||||
2980
server/drizzle/meta/0000_snapshot.json
Normal file
2980
server/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2981
server/drizzle/meta/0001_snapshot.json
Normal file
2981
server/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2982
server/drizzle/meta/0002_snapshot.json
Normal file
2982
server/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2983
server/drizzle/meta/0003_snapshot.json
Normal file
2983
server/drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2990
server/drizzle/meta/0004_snapshot.json
Normal file
2990
server/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3178
server/drizzle/meta/0005_snapshot.json
Normal file
3178
server/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3406
server/drizzle/meta/0006_snapshot.json
Normal file
3406
server/drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3538
server/drizzle/meta/0007_snapshot.json
Normal file
3538
server/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3573
server/drizzle/meta/0008_snapshot.json
Normal file
3573
server/drizzle/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3854
server/drizzle/meta/0009_snapshot.json
Normal file
3854
server/drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3872
server/drizzle/meta/0010_snapshot.json
Normal file
3872
server/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4110
server/drizzle/meta/0011_snapshot.json
Normal file
4110
server/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4646
server/drizzle/meta/0012_snapshot.json
Normal file
4646
server/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4686
server/drizzle/meta/0013_snapshot.json
Normal file
4686
server/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4686
server/drizzle/meta/0014_snapshot.json
Normal file
4686
server/drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4686
server/drizzle/meta/0015_snapshot.json
Normal file
4686
server/drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4852
server/drizzle/meta/0016_snapshot.json
Normal file
4852
server/drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
125
server/drizzle/meta/_journal.json
Normal file
125
server/drizzle/meta/_journal.json
Normal 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
52
server/package.json
Normal 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
168
server/src/app.ts
Normal 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
|
||||
}
|
||||
22
server/src/config-check.ts
Normal file
22
server/src/config-check.ts
Normal 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
113
server/src/config.test.ts
Normal 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
279
server/src/config.ts
Normal 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
17
server/src/db/client.ts
Normal 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
9
server/src/db/migrate.ts
Normal 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
566
server/src/db/schema.ts
Normal 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
269
server/src/db/seed.ts
Normal 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
23
server/src/index.ts
Normal 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)
|
||||
}
|
||||
40
server/src/lib/account-tokens.ts
Normal file
40
server/src/lib/account-tokens.ts
Normal 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
27
server/src/lib/audit.ts
Normal 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
100
server/src/lib/auth.ts
Normal 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()
|
||||
}
|
||||
18
server/src/lib/billing-plans.ts
Normal file
18
server/src/lib/billing-plans.ts
Normal 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
131
server/src/lib/billing.ts
Normal 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) })),
|
||||
}
|
||||
}
|
||||
68
server/src/lib/character-consistency.test.ts
Normal file
68
server/src/lib/character-consistency.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
116
server/src/lib/character-consistency.ts
Normal file
116
server/src/lib/character-consistency.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
39
server/src/lib/credential-vault.test.ts
Normal file
39
server/src/lib/credential-vault.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
51
server/src/lib/credential-vault.ts
Normal file
51
server/src/lib/credential-vault.ts
Normal 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
24
server/src/lib/errors.ts
Normal 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
20
server/src/lib/http.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
63
server/src/lib/mailer.test.ts
Normal file
63
server/src/lib/mailer.test.ts
Normal 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('<导演>')
|
||||
expect(String(requestBody.html)).toContain('北岸 & 工作室')
|
||||
expect(String(requestBody.html)).toContain('invite=token&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
128
server/src/lib/mailer.ts
Normal 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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
62
server/src/lib/metrics.ts
Normal 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],
|
||||
})
|
||||
35
server/src/lib/notifications.test.ts
Normal file
35
server/src/lib/notifications.test.ts
Normal 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: '服务商超时' })
|
||||
})
|
||||
})
|
||||
240
server/src/lib/notifications.ts
Normal file
240
server/src/lib/notifications.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
51
server/src/lib/permissions.ts
Normal file
51
server/src/lib/permissions.ts
Normal 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 }
|
||||
}
|
||||
90
server/src/lib/realtime.ts
Normal file
90
server/src/lib/realtime.ts
Normal 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
Reference in New Issue
Block a user