Spaces:
Runtime error
Runtime error
fix(security): enforce workspace membership authorization
#17
by basyx - opened
- .dockerignore +1 -0
- .gitignore +1 -0
- README.md +177 -7
- app/generation/models.py +2 -2
- app/generation/schemas/requests.py +1 -35
- app/projects/migrations/0014_collaboration_model_alignment.sql +26 -0
- app/projects/models/collaboration.py +103 -46
- app/security/context.py +17 -3
- app/security/database.py +34 -43
- app/security/migrations/0005_generation_brand_kit_snapshot_postgres.sql +15 -0
- app/security/scopes.py +22 -1
- app/security/service.py +4 -1
- app/social/domain/retry.py +8 -2
- app/social/providers/x.py +42 -125
- app/social/services/publishing_service.py +28 -16
- docs/audit/CI_BASELINE.md +7 -0
- docs/production-deployment-gate.md +23 -12
- docs/social-tenant-foundation.md +16 -0
.dockerignore
CHANGED
|
@@ -20,3 +20,4 @@ temp/*
|
|
| 20 |
!temp/.gitkeep
|
| 21 |
tests
|
| 22 |
requirements-dev.txt
|
|
|
|
|
|
| 20 |
!temp/.gitkeep
|
| 21 |
tests
|
| 22 |
requirements-dev.txt
|
| 23 |
+
_testvendor/
|
.gitignore
CHANGED
|
@@ -20,3 +20,4 @@ temp/*
|
|
| 20 |
*.sqlite
|
| 21 |
*.sqlite3
|
| 22 |
data/
|
|
|
|
|
|
| 20 |
*.sqlite
|
| 21 |
*.sqlite3
|
| 22 |
data/
|
| 23 |
+
_testvendor/
|
README.md
CHANGED
|
@@ -53,7 +53,7 @@ REST routes and MCP tools contain transport concerns only. Both use the same dep
|
|
| 53 |
## Project layout
|
| 54 |
|
| 55 |
```text
|
| 56 |
-
|
| 57 |
├── app/
|
| 58 |
│ ├── api/ # versioned, thin FastAPI routes
|
| 59 |
│ ├── core/ # settings, errors, logging, response models
|
|
@@ -124,10 +124,11 @@ docker run --rm -p 7860:7860 --env-file .env media-api
|
|
| 124 |
|
| 125 |
The image uses `python:3.10-slim`, installs FFmpeg, FFprobe, ImageMagick and the system libraries needed by CTranslate2/faster-whisper, clears apt and pip caches, runs as UID 1000, exposes port 7860, and includes a container health check.
|
| 126 |
|
| 127 |
-
The Docker image
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 131 |
|
| 132 |
## Deploy to Hugging Face Spaces
|
| 133 |
|
|
@@ -176,7 +177,7 @@ AUTH_BOOTSTRAP_ENVIRONMENT=live
|
|
| 176 |
|
| 177 |
The bootstrap fields are optional after the first successful start. If a Space fails at startup with `Bootstrap key hash or prefix is malformed`, remove duplicate or stale `AUTH_BOOTSTRAP_*` entries from both the Space **Variables** and **Secrets** panels, restart once, then recreate the three exact values above as Secrets. A blank hash and blank prefix deliberately disable bootstrap creation; a non-empty value must match the format exactly.
|
| 178 |
|
| 179 |
-
Recommended backend deployment settings are:
|
| 180 |
|
| 181 |
```env
|
| 182 |
APP_ENVIRONMENT=production
|
|
@@ -197,7 +198,7 @@ is the public Hugging Face origin, not the Vercel frontend URL.
|
|
| 197 |
|
| 198 |
### Vercel environment variables
|
| 199 |
|
| 200 |
-
|
| 201 |
|
| 202 |
```env
|
| 203 |
# Server-only backend connection and credential. Never use NEXT_PUBLIC_ for a key.
|
|
@@ -243,6 +244,175 @@ MEDIAROUTER_VIEWER_API_TOKEN=mp_live_<viewer-key>
|
|
| 243 |
|
| 244 |
GitHub’s production callback URL is `https://<your-vercel-project>.vercel.app/api/auth/callback/github`; Google’s is the equivalent `/api/auth/callback/google`. Generate `AUTH_SECRET` with `openssl rand -base64 32`. Never put API keys, OAuth client secrets, or `AUTH_SECRET` in a `NEXT_PUBLIC_*` variable.
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
### Verify the complete connection
|
| 247 |
|
| 248 |
1. Confirm `https://basyx-mediarouter.hf.space/health` returns `200`.
|
|
|
|
| 53 |
## Project layout
|
| 54 |
|
| 55 |
```text
|
| 56 |
+
MediaForge/
|
| 57 |
├── app/
|
| 58 |
│ ├── api/ # versioned, thin FastAPI routes
|
| 59 |
│ ├── core/ # settings, errors, logging, response models
|
|
|
|
| 124 |
|
| 125 |
The image uses `python:3.10-slim`, installs FFmpeg, FFprobe, ImageMagick and the system libraries needed by CTranslate2/faster-whisper, clears apt and pip caches, runs as UID 1000, exposes port 7860, and includes a container health check.
|
| 126 |
|
| 127 |
+
The Docker image defaults to `APP_ENVIRONMENT=production` and fails closed until
|
| 128 |
+
external PostgreSQL/Supabase, the documented migrations, and an exact Vercel
|
| 129 |
+
CORS origin are configured. For local Docker development, override the image
|
| 130 |
+
default with `APP_ENVIRONMENT=development` and use the SQLite development URL.
|
| 131 |
+
See [the production deployment gate](docs/production-deployment-gate.md).
|
| 132 |
|
| 133 |
## Deploy to Hugging Face Spaces
|
| 134 |
|
|
|
|
| 177 |
|
| 178 |
The bootstrap fields are optional after the first successful start. If a Space fails at startup with `Bootstrap key hash or prefix is malformed`, remove duplicate or stale `AUTH_BOOTSTRAP_*` entries from both the Space **Variables** and **Secrets** panels, restart once, then recreate the three exact values above as Secrets. A blank hash and blank prefix deliberately disable bootstrap creation; a non-empty value must match the format exactly.
|
| 179 |
|
| 180 |
+
Recommended strict production backend deployment settings are:
|
| 181 |
|
| 182 |
```env
|
| 183 |
APP_ENVIRONMENT=production
|
|
|
|
| 198 |
|
| 199 |
### Vercel environment variables
|
| 200 |
|
| 201 |
+
The active Vercel control-plane is maintained in the separate GitHub repository `ava2lon/mediarouter-studio` (Next.js 15.2.4). Configure that repository in Vercel with its repository root as the project root; do not set `frontend/` as the root of this MediaForge repository. The `frontend/` directory here is a separate frontend workspace and is not the currently deployed Vercel source. Configure the following values for **Production** and for **Preview** if preview deployments need to connect to the backend. Redeploy after changing an environment variable.
|
| 202 |
|
| 203 |
```env
|
| 204 |
# Server-only backend connection and credential. Never use NEXT_PUBLIC_ for a key.
|
|
|
|
| 244 |
|
| 245 |
GitHub’s production callback URL is `https://<your-vercel-project>.vercel.app/api/auth/callback/github`; Google’s is the equivalent `/api/auth/callback/google`. Generate `AUTH_SECRET` with `openssl rand -base64 32`. Never put API keys, OAuth client secrets, or `AUTH_SECRET` in a `NEXT_PUBLIC_*` variable.
|
| 246 |
|
| 247 |
+
### Deployment environment checklist
|
| 248 |
+
|
| 249 |
+
Use Hugging Face Space **Secrets** or protected variables for backend-only values,
|
| 250 |
+
especially database URLs, OAuth client secrets, provider tokens, bootstrap
|
| 251 |
+
hashes, and service role keys. Use Vercel environment variables for frontend
|
| 252 |
+
server-side values. Only variables beginning with `NEXT_PUBLIC_` are safe to
|
| 253 |
+
expose to browser code.
|
| 254 |
+
|
| 255 |
+
Backend variables from `.env.example`:
|
| 256 |
+
|
| 257 |
+
```text
|
| 258 |
+
APP_NAME
|
| 259 |
+
APP_VERSION
|
| 260 |
+
APP_ENVIRONMENT
|
| 261 |
+
HOST
|
| 262 |
+
PORT
|
| 263 |
+
CORS_ALLOWED_ORIGINS
|
| 264 |
+
TEMP_DIR
|
| 265 |
+
OUTPUT_DIR
|
| 266 |
+
TEMPLATE_DIR
|
| 267 |
+
MAX_UPLOAD_SIZE
|
| 268 |
+
WHISPER_MODEL
|
| 269 |
+
CLEANUP_MINUTES
|
| 270 |
+
CLEANUP_INTERVAL_SECONDS
|
| 271 |
+
MAX_WORKERS
|
| 272 |
+
LOG_LEVEL
|
| 273 |
+
MAX_DURATION_SECONDS
|
| 274 |
+
MAX_RESOLUTION_PIXELS
|
| 275 |
+
DOWNLOAD_TIMEOUT_SECONDS
|
| 276 |
+
ALLOW_PRIVATE_URLS
|
| 277 |
+
BASE_URL
|
| 278 |
+
FFMPEG_BINARY
|
| 279 |
+
FFPROBE_BINARY
|
| 280 |
+
AUTH_ENABLED
|
| 281 |
+
DATABASE_URL
|
| 282 |
+
SECURITY_AUTO_MIGRATE
|
| 283 |
+
SECURITY_DATABASE_ROLE
|
| 284 |
+
SECURITY_ENFORCE_RLS
|
| 285 |
+
AUTH_ROLE_SCOPES
|
| 286 |
+
AUTH_BOOTSTRAP_KEY_HASH
|
| 287 |
+
AUTH_BOOTSTRAP_KEY_PREFIX
|
| 288 |
+
AUTH_BOOTSTRAP_KEY_NAME
|
| 289 |
+
AUTH_BOOTSTRAP_ENVIRONMENT
|
| 290 |
+
AUTH_LAST_USED_UPDATE_SECONDS
|
| 291 |
+
AUTH_DEFAULT_REQUESTS_PER_MINUTE
|
| 292 |
+
AUTH_DEFAULT_CONCURRENT_JOBS
|
| 293 |
+
AUTH_DEFAULT_UPLOADS_PER_HOUR
|
| 294 |
+
AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY
|
| 295 |
+
AUTH_TRUST_PROXY_HEADERS
|
| 296 |
+
MCP_STDIO_API_KEY
|
| 297 |
+
SOCIAL_ENABLED
|
| 298 |
+
SOCIAL_DATABASE_URL
|
| 299 |
+
SOCIAL_TENANT_DATABASE_ROLE
|
| 300 |
+
SOCIAL_WORKER_DATABASE_URL
|
| 301 |
+
SOCIAL_WORKER_DATABASE_ROLE
|
| 302 |
+
SOCIAL_ENFORCE_RLS
|
| 303 |
+
SOCIAL_AUTO_MIGRATE
|
| 304 |
+
SOCIAL_WORKER_ENABLED
|
| 305 |
+
SOCIAL_SCHEDULER_INTERVAL_SECONDS
|
| 306 |
+
SOCIAL_JOB_STALE_AFTER_SECONDS
|
| 307 |
+
SOCIAL_PUBLISH_RETRY_LIMIT
|
| 308 |
+
SOCIAL_OAUTH_REQUESTS_PER_HOUR
|
| 309 |
+
SOCIAL_PUBLISH_REQUESTS_PER_MINUTE
|
| 310 |
+
SOCIAL_SCHEDULE_REQUESTS_PER_MINUTE
|
| 311 |
+
SOCIAL_ANALYTICS_REQUESTS_PER_MINUTE
|
| 312 |
+
SOCIAL_OAUTH_ENCRYPTION_KEY
|
| 313 |
+
SUPABASE_URL
|
| 314 |
+
SUPABASE_SERVICE_ROLE_KEY
|
| 315 |
+
SUPABASE_VAULT_ENABLED
|
| 316 |
+
SOCIAL_OAUTH_REDIRECT_BASE_URL
|
| 317 |
+
GENERATION_ENABLED
|
| 318 |
+
GENERATION_JOB_RETRY_LIMIT
|
| 319 |
+
AI_WORKER_CONNECT_TIMEOUT_SECONDS
|
| 320 |
+
AI_WORKER_REQUEST_TIMEOUT_SECONDS
|
| 321 |
+
AI_WORKER_READ_TIMEOUT_SECONDS
|
| 322 |
+
AI_WORKER_MAX_RETRIES
|
| 323 |
+
AI_WORKER_RETRY_BACKOFF_SECONDS
|
| 324 |
+
WAN_SPACE_URL
|
| 325 |
+
WAN_SPACE_TOKEN
|
| 326 |
+
FLUX_SPACE_URL
|
| 327 |
+
FLUX_SPACE_TOKEN
|
| 328 |
+
GENERATION_WORKER_ENABLED
|
| 329 |
+
GENERATION_WORKER_INTERVAL_SECONDS
|
| 330 |
+
GENERATION_WORKER_POLL_BACKOFF_SECONDS
|
| 331 |
+
GENERATION_WORKER_BATCH_SIZE
|
| 332 |
+
GENERATION_JOB_STALE_AFTER_SECONDS
|
| 333 |
+
EDITOR_STATE_MAX_BYTES
|
| 334 |
+
RENDER_WORKER_ENABLED
|
| 335 |
+
RENDER_WORKER_INTERVAL_SECONDS
|
| 336 |
+
RENDER_JOB_STALE_AFTER_SECONDS
|
| 337 |
+
RENDER_JOB_TIMEOUT_SECONDS
|
| 338 |
+
RENDER_JOB_RETRY_LIMIT
|
| 339 |
+
RENDER_MAX_ACTIVE_JOBS_PER_PROJECT
|
| 340 |
+
RENDER_MAX_TRACKS
|
| 341 |
+
RENDER_MAX_CLIPS
|
| 342 |
+
RENDER_MAX_DURATION_SECONDS
|
| 343 |
+
RENDER_MAX_INPUT_BYTES
|
| 344 |
+
GOOGLE_CLIENT_ID
|
| 345 |
+
GOOGLE_CLIENT_SECRET
|
| 346 |
+
YOUTUBE_UPLOAD_CHUNK_BYTES
|
| 347 |
+
YOUTUBE_MAX_CONCURRENT_UPLOADS
|
| 348 |
+
YOUTUBE_REQUEST_TIMEOUT_SECONDS
|
| 349 |
+
YOUTUBE_PROCESSING_POLL_SECONDS
|
| 350 |
+
META_CLIENT_ID
|
| 351 |
+
META_CLIENT_SECRET
|
| 352 |
+
META_APP_ID
|
| 353 |
+
META_APP_SECRET
|
| 354 |
+
META_GRAPH_API_VERSION
|
| 355 |
+
TIKTOK_CLIENT_KEY
|
| 356 |
+
TIKTOK_CLIENT_SECRET
|
| 357 |
+
TIKTOK_REDIRECT_URI
|
| 358 |
+
TIKTOK_DIRECT_POST_ENABLED
|
| 359 |
+
TIKTOK_UPLOAD_CHUNK_BYTES
|
| 360 |
+
TIKTOK_REQUEST_TIMEOUT_SECONDS
|
| 361 |
+
TIKTOK_PROCESSING_POLL_SECONDS
|
| 362 |
+
RUN_TIKTOK_INTEGRATION_TESTS
|
| 363 |
+
LINKEDIN_CLIENT_ID
|
| 364 |
+
LINKEDIN_CLIENT_SECRET
|
| 365 |
+
LINKEDIN_REDIRECT_URI
|
| 366 |
+
LINKEDIN_PUBLISHING_ENABLED
|
| 367 |
+
LINKEDIN_REQUEST_TIMEOUT_SECONDS
|
| 368 |
+
LINKEDIN_MEDIA_PROCESSING_POLL_SECONDS
|
| 369 |
+
LINKEDIN_MEDIA_PROCESSING_TIMEOUT_SECONDS
|
| 370 |
+
RUN_LINKEDIN_INTEGRATION_TESTS
|
| 371 |
+
X_CLIENT_ID
|
| 372 |
+
X_CLIENT_SECRET
|
| 373 |
+
X_REDIRECT_URI
|
| 374 |
+
X_PUBLISHING_ENABLED
|
| 375 |
+
X_UPLOAD_CHUNK_BYTES
|
| 376 |
+
X_REQUEST_TIMEOUT_SECONDS
|
| 377 |
+
X_MEDIA_PROCESSING_POLL_SECONDS
|
| 378 |
+
X_MEDIA_PROCESSING_TIMEOUT_SECONDS
|
| 379 |
+
RUN_X_INTEGRATION_TESTS
|
| 380 |
+
TELEGRAM_BOT_TOKEN
|
| 381 |
+
WHATSAPP_CLIENT_ID
|
| 382 |
+
WHATSAPP_CLIENT_SECRET
|
| 383 |
+
```
|
| 384 |
+
|
| 385 |
+
Frontend variables from `frontend/.env.example`:
|
| 386 |
+
|
| 387 |
+
```text
|
| 388 |
+
MEDIAROUTER_API_URL
|
| 389 |
+
MEDIAROUTER_MCP_URL
|
| 390 |
+
MEDIAROUTER_API_TIMEOUT
|
| 391 |
+
AUTH_SECRET
|
| 392 |
+
NEXTAUTH_URL
|
| 393 |
+
AUTH_GITHUB_ID
|
| 394 |
+
AUTH_GITHUB_SECRET
|
| 395 |
+
AUTH_GOOGLE_ID
|
| 396 |
+
AUTH_GOOGLE_SECRET
|
| 397 |
+
AUTH_USER_ROLES
|
| 398 |
+
AUTH_ADMIN_EMAILS
|
| 399 |
+
AUTH_DEVELOPER_EMAILS
|
| 400 |
+
AUTH_OPERATOR_EMAILS
|
| 401 |
+
AUTH_VIEWER_EMAILS
|
| 402 |
+
AUTH_DEFAULT_ROLE
|
| 403 |
+
AUTH_SESSION_MAX_AGE
|
| 404 |
+
MEDIAROUTER_ADMIN_API_TOKEN
|
| 405 |
+
MEDIAROUTER_DEVELOPER_API_TOKEN
|
| 406 |
+
MEDIAROUTER_OPERATOR_API_TOKEN
|
| 407 |
+
MEDIAROUTER_VIEWER_API_TOKEN
|
| 408 |
+
MEDIAROUTER_API_TOKEN
|
| 409 |
+
NEXT_PUBLIC_API_URL
|
| 410 |
+
NEXT_PUBLIC_MCP_URL
|
| 411 |
+
NEXT_PUBLIC_APP_NAME
|
| 412 |
+
NEXT_PUBLIC_ENABLE_MCP
|
| 413 |
+
NEXT_PUBLIC_ENABLE_MARKETPLACE
|
| 414 |
+
```
|
| 415 |
+
|
| 416 |
### Verify the complete connection
|
| 417 |
|
| 418 |
1. Confirm `https://basyx-mediarouter.hf.space/health` returns `200`.
|
app/generation/models.py
CHANGED
|
@@ -95,9 +95,10 @@ class GenerationJob(Base):
|
|
| 95 |
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 96 |
)
|
| 97 |
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
|
|
|
|
| 98 |
brand_kit_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
| 99 |
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
| 100 |
-
progress_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 101 |
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 102 |
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
| 103 |
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
@@ -141,7 +142,6 @@ class GenerationJobAttempt(Base):
|
|
| 141 |
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 142 |
brand_kit_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
| 143 |
external_job_id: Mapped[str | None] = mapped_column(String(128))
|
| 144 |
-
error_code: Mapped[str | None] = mapped_column(String(100))
|
| 145 |
error_message: Mapped[str | None] = mapped_column(Text)
|
| 146 |
provider_request_id: Mapped[str | None] = mapped_column(String(255))
|
| 147 |
started_at: Mapped[datetime] = mapped_column(
|
|
|
|
| 95 |
String(36), ForeignKey("workspaces.id", ondelete="RESTRICT"), nullable=False
|
| 96 |
)
|
| 97 |
provider: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 98 |
+
# Snapshot the selected brand-kit version on the logical job so retries
|
| 99 |
+
# and result views retain the exact creative policy used at submission.
|
| 100 |
brand_kit_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
| 101 |
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
|
|
|
| 102 |
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 103 |
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
| 104 |
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
|
|
| 142 |
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 143 |
brand_kit_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
| 144 |
external_job_id: Mapped[str | None] = mapped_column(String(128))
|
|
|
|
| 145 |
error_message: Mapped[str | None] = mapped_column(Text)
|
| 146 |
provider_request_id: Mapped[str | None] = mapped_column(String(255))
|
| 147 |
started_at: Mapped[datetime] = mapped_column(
|
app/generation/schemas/requests.py
CHANGED
|
@@ -31,10 +31,6 @@ class GenerationRequestCreate(BaseModel):
|
|
| 31 |
default=None,
|
| 32 |
pattern=(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-" r"[89ab][0-9a-f]{3}-[0-9a-f]{12}$"),
|
| 33 |
)
|
| 34 |
-
project_id: str | None = Field(
|
| 35 |
-
default=None,
|
| 36 |
-
pattern=(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-" r"[89ab][0-9a-f]{3}-[0-9a-f]{12}$"),
|
| 37 |
-
)
|
| 38 |
brand_kit_version_id: str | None = Field(default=None, min_length=1, max_length=36)
|
| 39 |
# Concrete-provider controls remain closed, typed schemas rather than an
|
| 40 |
# escape hatch for arbitrary worker payload. Provider-specific fields must
|
|
@@ -127,7 +123,6 @@ class GenerationJobView(BaseModel):
|
|
| 127 |
id: str
|
| 128 |
generation_request_id: str
|
| 129 |
status: GenerationJobStatus
|
| 130 |
-
progress_percent: int | None = Field(default=None, ge=0, le=100)
|
| 131 |
attempt_count: int
|
| 132 |
max_attempts: int
|
| 133 |
brand_kit_version_id: str | None = None
|
|
@@ -147,8 +142,7 @@ class GenerationJobView(BaseModel):
|
|
| 147 |
"id": getattr(record, "id"),
|
| 148 |
"generation_request_id": getattr(record, "generation_request_id"),
|
| 149 |
"status": getattr(record, "status"),
|
| 150 |
-
"
|
| 151 |
-
"attempt_count": getattr(record, "attempt_number"),
|
| 152 |
"max_attempts": getattr(record, "max_attempts"),
|
| 153 |
"brand_kit_version_id": getattr(record, "brand_kit_version_id", None),
|
| 154 |
"output_asset_id": getattr(record, "output_asset_id"),
|
|
@@ -163,34 +157,6 @@ class GenerationJobView(BaseModel):
|
|
| 163 |
)
|
| 164 |
|
| 165 |
|
| 166 |
-
class GenerationJobAttemptView(BaseModel):
|
| 167 |
-
"""Safe, durable status and error history for one generation attempt."""
|
| 168 |
-
|
| 169 |
-
model_config = ConfigDict(extra="forbid")
|
| 170 |
-
|
| 171 |
-
id: str
|
| 172 |
-
generation_job_id: str
|
| 173 |
-
attempt_number: int
|
| 174 |
-
status: str
|
| 175 |
-
error_code: str | None = None
|
| 176 |
-
error_message: str | None = None
|
| 177 |
-
started_at: datetime
|
| 178 |
-
completed_at: datetime | None = None
|
| 179 |
-
|
| 180 |
-
@classmethod
|
| 181 |
-
def from_record(cls, record: object) -> "GenerationJobAttemptView":
|
| 182 |
-
return cls(
|
| 183 |
-
id=getattr(record, "id"),
|
| 184 |
-
generation_job_id=getattr(record, "generation_job_id"),
|
| 185 |
-
attempt_number=getattr(record, "attempt_number"),
|
| 186 |
-
status=getattr(record, "status"),
|
| 187 |
-
error_code=getattr(record, "error_code", None),
|
| 188 |
-
error_message=getattr(record, "error_message", None),
|
| 189 |
-
started_at=getattr(record, "started_at"),
|
| 190 |
-
completed_at=getattr(record, "completed_at"),
|
| 191 |
-
)
|
| 192 |
-
|
| 193 |
-
|
| 194 |
class GenerationRequestView(BaseModel):
|
| 195 |
model_config = ConfigDict(extra="forbid")
|
| 196 |
|
|
|
|
| 31 |
default=None,
|
| 32 |
pattern=(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-" r"[89ab][0-9a-f]{3}-[0-9a-f]{12}$"),
|
| 33 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
brand_kit_version_id: str | None = Field(default=None, min_length=1, max_length=36)
|
| 35 |
# Concrete-provider controls remain closed, typed schemas rather than an
|
| 36 |
# escape hatch for arbitrary worker payload. Provider-specific fields must
|
|
|
|
| 123 |
id: str
|
| 124 |
generation_request_id: str
|
| 125 |
status: GenerationJobStatus
|
|
|
|
| 126 |
attempt_count: int
|
| 127 |
max_attempts: int
|
| 128 |
brand_kit_version_id: str | None = None
|
|
|
|
| 142 |
"id": getattr(record, "id"),
|
| 143 |
"generation_request_id": getattr(record, "generation_request_id"),
|
| 144 |
"status": getattr(record, "status"),
|
| 145 |
+
"attempt_count": getattr(record, "attempt_count"),
|
|
|
|
| 146 |
"max_attempts": getattr(record, "max_attempts"),
|
| 147 |
"brand_kit_version_id": getattr(record, "brand_kit_version_id", None),
|
| 148 |
"output_asset_id": getattr(record, "output_asset_id"),
|
|
|
|
| 157 |
)
|
| 158 |
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
class GenerationRequestView(BaseModel):
|
| 161 |
model_config = ConfigDict(extra="forbid")
|
| 162 |
|
app/projects/migrations/0014_collaboration_model_alignment.sql
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
begin;
|
| 2 |
+
|
| 3 |
+
-- The collaboration ORM stores only a hash of invitation tokens and exposes
|
| 4 |
+
-- the creation timestamp in InvitationResponse. Keep the PostgreSQL table in
|
| 5 |
+
-- sync with those fields.
|
| 6 |
+
alter table invitations
|
| 7 |
+
add column if not exists token_hash varchar(255);
|
| 8 |
+
|
| 9 |
+
-- The original foundation migration used the singular table name while the
|
| 10 |
+
-- ORM and repository use the plural name. Rename in place to preserve rows,
|
| 11 |
+
-- constraints, and the existing RLS policy.
|
| 12 |
+
do $$
|
| 13 |
+
begin
|
| 14 |
+
if to_regclass('public.collaboration_activities') is null
|
| 15 |
+
and to_regclass('public.collaboration_activity') is not null then
|
| 16 |
+
alter table collaboration_activity rename to collaboration_activities;
|
| 17 |
+
end if;
|
| 18 |
+
end
|
| 19 |
+
$$;
|
| 20 |
+
|
| 21 |
+
create index if not exists ix_collaboration_activities_workspace_created
|
| 22 |
+
on collaboration_activities(workspace_id, created_at desc);
|
| 23 |
+
|
| 24 |
+
drop index if exists ix_collaboration_activity_workspace_id;
|
| 25 |
+
|
| 26 |
+
commit;
|
app/projects/models/collaboration.py
CHANGED
|
@@ -16,30 +16,43 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|
| 16 |
|
| 17 |
from app.security.models import Base, utcnow
|
| 18 |
|
|
|
|
| 19 |
class Team(Base):
|
| 20 |
__tablename__ = "teams"
|
| 21 |
-
__table_args__ = (
|
| 22 |
-
Index("ix_teams_workspace_id", "workspace_id"),
|
| 23 |
-
)
|
| 24 |
|
| 25 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 26 |
-
workspace_id: Mapped[str] = mapped_column(
|
|
|
|
|
|
|
| 27 |
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 28 |
-
created_at: Mapped[datetime] = mapped_column(
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
class TeamMember(Base):
|
| 32 |
__tablename__ = "team_members"
|
| 33 |
-
__table_args__ = (
|
| 34 |
-
UniqueConstraint("team_id", "user_id", name="uq_team_member"),
|
| 35 |
-
)
|
| 36 |
|
| 37 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 38 |
-
workspace_id: Mapped[str] = mapped_column(
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 42 |
-
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
class ProjectCollaborator(Base):
|
| 45 |
__tablename__ = "project_collaborators"
|
|
@@ -49,11 +62,20 @@ class ProjectCollaborator(Base):
|
|
| 49 |
)
|
| 50 |
|
| 51 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 52 |
-
workspace_id: Mapped[str] = mapped_column(
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 56 |
-
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
class CollaborationActivity(Base):
|
| 59 |
__tablename__ = "collaboration_activities"
|
|
@@ -62,62 +84,97 @@ class CollaborationActivity(Base):
|
|
| 62 |
)
|
| 63 |
|
| 64 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 65 |
-
workspace_id: Mapped[str] = mapped_column(
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 68 |
entity_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
| 69 |
entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 70 |
-
metadata_json: Mapped[dict[str, object]] = mapped_column(
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
class Invitation(Base):
|
| 73 |
__tablename__ = "invitations"
|
| 74 |
-
__table_args__ = (
|
| 75 |
-
Index("ix_invitations_workspace_id", "workspace_id"),
|
| 76 |
-
)
|
| 77 |
|
| 78 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 79 |
-
workspace_id: Mapped[str] = mapped_column(
|
|
|
|
|
|
|
| 80 |
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 81 |
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 82 |
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
| 83 |
token_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 84 |
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
class ApprovalWorkflow(Base):
|
| 86 |
__tablename__ = "approval_workflows"
|
| 87 |
-
__table_args__ = (
|
| 88 |
-
Index("ix_approval_workflows_project_id", "project_id"),
|
| 89 |
-
)
|
| 90 |
|
| 91 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 92 |
-
workspace_id: Mapped[str] = mapped_column(
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 95 |
-
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
class ApprovalRequest(Base):
|
| 98 |
__tablename__ = "approval_requests"
|
| 99 |
-
__table_args__ = (
|
| 100 |
-
Index("ix_approval_requests_workflow_id", "workflow_id"),
|
| 101 |
-
)
|
| 102 |
|
| 103 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 104 |
-
workspace_id: Mapped[str] = mapped_column(
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
editor_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
| 108 |
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
| 109 |
-
created_by: Mapped[str] = mapped_column(
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
class ReviewComment(Base):
|
| 113 |
__tablename__ = "review_comments"
|
| 114 |
-
__table_args__ = (
|
| 115 |
-
Index("ix_review_comments_request_id", "request_id"),
|
| 116 |
-
)
|
| 117 |
|
| 118 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 119 |
-
workspace_id: Mapped[str] = mapped_column(
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
content: Mapped[str] = mapped_column(String(2000), nullable=False)
|
| 123 |
-
created_at: Mapped[datetime] = mapped_column(
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
from app.security.models import Base, utcnow
|
| 18 |
|
| 19 |
+
|
| 20 |
class Team(Base):
|
| 21 |
__tablename__ = "teams"
|
| 22 |
+
__table_args__ = (Index("ix_teams_workspace_id", "workspace_id"),)
|
|
|
|
|
|
|
| 23 |
|
| 24 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 25 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 26 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 27 |
+
)
|
| 28 |
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 29 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 30 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 31 |
+
)
|
| 32 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 33 |
+
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
|
| 37 |
class TeamMember(Base):
|
| 38 |
__tablename__ = "team_members"
|
| 39 |
+
__table_args__ = (UniqueConstraint("team_id", "user_id", name="uq_team_member"),)
|
|
|
|
|
|
|
| 40 |
|
| 41 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 42 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 43 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 44 |
+
)
|
| 45 |
+
team_id: Mapped[str] = mapped_column(
|
| 46 |
+
String(36), ForeignKey("teams.id", ondelete="CASCADE"), nullable=False
|
| 47 |
+
)
|
| 48 |
+
user_id: Mapped[str] = mapped_column(
|
| 49 |
+
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
| 50 |
+
)
|
| 51 |
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 52 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 53 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
|
| 57 |
class ProjectCollaborator(Base):
|
| 58 |
__tablename__ = "project_collaborators"
|
|
|
|
| 62 |
)
|
| 63 |
|
| 64 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 65 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 66 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 67 |
+
)
|
| 68 |
+
project_id: Mapped[str] = mapped_column(
|
| 69 |
+
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
| 70 |
+
)
|
| 71 |
+
user_id: Mapped[str] = mapped_column(
|
| 72 |
+
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
| 73 |
+
)
|
| 74 |
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 75 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 76 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
|
| 80 |
class CollaborationActivity(Base):
|
| 81 |
__tablename__ = "collaboration_activities"
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 87 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 88 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 89 |
+
)
|
| 90 |
+
user_id: Mapped[str] = mapped_column(
|
| 91 |
+
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
| 92 |
+
)
|
| 93 |
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 94 |
entity_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
| 95 |
entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 96 |
+
metadata_json: Mapped[dict[str, object]] = mapped_column(
|
| 97 |
+
"metadata", JSON, nullable=False, default=dict
|
| 98 |
+
)
|
| 99 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 100 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
class Invitation(Base):
|
| 105 |
__tablename__ = "invitations"
|
| 106 |
+
__table_args__ = (Index("ix_invitations_workspace_id", "workspace_id"),)
|
|
|
|
|
|
|
| 107 |
|
| 108 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 109 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 110 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 111 |
+
)
|
| 112 |
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 113 |
role: Mapped[str] = mapped_column(String(50), nullable=False)
|
| 114 |
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
| 115 |
token_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 116 |
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 117 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 118 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
class ApprovalWorkflow(Base):
|
| 123 |
__tablename__ = "approval_workflows"
|
| 124 |
+
__table_args__ = (Index("ix_approval_workflows_project_id", "project_id"),)
|
|
|
|
|
|
|
| 125 |
|
| 126 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 127 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 128 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 129 |
+
)
|
| 130 |
+
project_id: Mapped[str] = mapped_column(
|
| 131 |
+
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
| 132 |
+
)
|
| 133 |
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
| 134 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 135 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
|
| 139 |
class ApprovalRequest(Base):
|
| 140 |
__tablename__ = "approval_requests"
|
| 141 |
+
__table_args__ = (Index("ix_approval_requests_workflow_id", "workflow_id"),)
|
|
|
|
|
|
|
| 142 |
|
| 143 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 144 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 145 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 146 |
+
)
|
| 147 |
+
workflow_id: Mapped[str] = mapped_column(
|
| 148 |
+
String(36), ForeignKey("approval_workflows.id", ondelete="CASCADE"), nullable=False
|
| 149 |
+
)
|
| 150 |
+
project_id: Mapped[str] = mapped_column(
|
| 151 |
+
String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
| 152 |
+
)
|
| 153 |
editor_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
| 154 |
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
| 155 |
+
created_by: Mapped[str] = mapped_column(
|
| 156 |
+
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
| 157 |
+
)
|
| 158 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 159 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
|
| 163 |
class ReviewComment(Base):
|
| 164 |
__tablename__ = "review_comments"
|
| 165 |
+
__table_args__ = (Index("ix_review_comments_request_id", "request_id"),)
|
|
|
|
|
|
|
| 166 |
|
| 167 |
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 168 |
+
workspace_id: Mapped[str] = mapped_column(
|
| 169 |
+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
| 170 |
+
)
|
| 171 |
+
request_id: Mapped[str] = mapped_column(
|
| 172 |
+
String(36), ForeignKey("approval_requests.id", ondelete="CASCADE"), nullable=False
|
| 173 |
+
)
|
| 174 |
+
user_id: Mapped[str] = mapped_column(
|
| 175 |
+
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
| 176 |
+
)
|
| 177 |
content: Mapped[str] = mapped_column(String(2000), nullable=False)
|
| 178 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 179 |
+
DateTime(timezone=True), nullable=False, default=utcnow
|
| 180 |
+
)
|
app/security/context.py
CHANGED
|
@@ -4,6 +4,8 @@ import contextvars
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from datetime import datetime
|
| 6 |
|
|
|
|
|
|
|
| 7 |
|
| 8 |
@dataclass(frozen=True, slots=True)
|
| 9 |
class AuthContext:
|
|
@@ -17,18 +19,30 @@ class AuthContext:
|
|
| 17 |
concurrent_jobs: int
|
| 18 |
uploads_per_hour: int
|
| 19 |
processing_bytes_per_day: int
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
# Resolved exclusively by the server from APIKeyPrincipal. These are not
|
| 22 |
# accepted from REST, MCP, SDK, or n8n request payloads.
|
| 23 |
workspace_id: str | None = None
|
| 24 |
user_id: str | None = None
|
| 25 |
membership_id: str | None = None
|
| 26 |
membership_role: str | None = None
|
|
|
|
| 27 |
|
| 28 |
def allows(self, required_scope: str) -> bool:
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
return True
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
auth_context: contextvars.ContextVar[AuthContext | None] = contextvars.ContextVar(
|
|
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from datetime import datetime
|
| 6 |
|
| 7 |
+
from app.security.scopes import membership_role_scopes
|
| 8 |
+
|
| 9 |
|
| 10 |
@dataclass(frozen=True, slots=True)
|
| 11 |
class AuthContext:
|
|
|
|
| 19 |
concurrent_jobs: int
|
| 20 |
uploads_per_hour: int
|
| 21 |
processing_bytes_per_day: int
|
| 22 |
+
# API keys may be configured without an expiry; callers that omit the
|
| 23 |
+
# field therefore receive the same explicit non-expiring semantics.
|
| 24 |
+
expires_at: datetime | None = None
|
| 25 |
# Resolved exclusively by the server from APIKeyPrincipal. These are not
|
| 26 |
# accepted from REST, MCP, SDK, or n8n request payloads.
|
| 27 |
workspace_id: str | None = None
|
| 28 |
user_id: str | None = None
|
| 29 |
membership_id: str | None = None
|
| 30 |
membership_role: str | None = None
|
| 31 |
+
membership_scopes: frozenset[str] | None = None
|
| 32 |
|
| 33 |
def allows(self, required_scope: str) -> bool:
|
| 34 |
+
key_allows = required_scope in self.scopes or "admin" in self.scopes
|
| 35 |
+
if not key_allows:
|
| 36 |
+
return False
|
| 37 |
+
# API-key scopes are a credential ceiling; workspace membership is an
|
| 38 |
+
# independent tenant ceiling. A powerful key must never elevate a
|
| 39 |
+
# viewer/member beyond the role granted by the workspace owner.
|
| 40 |
+
if self.membership_role is None:
|
| 41 |
return True
|
| 42 |
+
membership_scopes = self.membership_scopes
|
| 43 |
+
if membership_scopes is None:
|
| 44 |
+
membership_scopes = membership_role_scopes(self.membership_role)
|
| 45 |
+
return required_scope in membership_scopes or "admin" in membership_scopes
|
| 46 |
|
| 47 |
|
| 48 |
auth_context: contextvars.ContextVar[AuthContext | None] = contextvars.ContextVar(
|
app/security/database.py
CHANGED
|
@@ -49,8 +49,23 @@ REQUIRED_SECURITY_TABLES = frozenset(
|
|
| 49 |
"marketplace_template_applications",
|
| 50 |
"brand_kits",
|
| 51 |
"brand_kit_versions",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
REQUIRED_POSTGRES_SECURITY_INDEXES = frozenset(
|
| 55 |
{
|
| 56 |
"uq_generation_job_provider_external",
|
|
@@ -86,6 +101,8 @@ REQUIRED_POSTGRES_SECURITY_INDEXES = frozenset(
|
|
| 86 |
"uq_brand_kits_one_default",
|
| 87 |
"uq_brand_kit_version",
|
| 88 |
"ix_brand_versions_kit_created",
|
|
|
|
|
|
|
| 89 |
}
|
| 90 |
)
|
| 91 |
|
|
@@ -97,24 +114,9 @@ class SecurityDatabase:
|
|
| 97 |
database_url = normalize_async_database_url(database_url)
|
| 98 |
self.database_url = database_url
|
| 99 |
self.auto_migrate = auto_migrate or database_url.startswith("sqlite")
|
| 100 |
-
# Disable asyncpg's prepared-statement cache for PostgreSQL poolers.
|
| 101 |
-
# Supabase/PgBouncer transaction pooling can otherwise reuse a server
|
| 102 |
-
# connection whose prepared-statement names were created by another
|
| 103 |
-
# client connection, raising DuplicatePreparedStatementError.
|
| 104 |
-
connect_args = (
|
| 105 |
-
{
|
| 106 |
-
"statement_cache_size": 0,
|
| 107 |
-
# Keep every unqualified ORM table reference inside MediaRouter.
|
| 108 |
-
# Do not include public in this path: it contains ConversationHub data.
|
| 109 |
-
"server_settings": {"search_path": "mediarouter"},
|
| 110 |
-
}
|
| 111 |
-
if database_url.startswith(("postgresql", "postgres"))
|
| 112 |
-
else {}
|
| 113 |
-
)
|
| 114 |
self.engine: AsyncEngine = create_async_engine(
|
| 115 |
database_url,
|
| 116 |
pool_pre_ping=True,
|
| 117 |
-
connect_args=connect_args,
|
| 118 |
)
|
| 119 |
if database_url.startswith("sqlite"):
|
| 120 |
event.listen(self.engine.sync_engine, "connect", self._configure_sqlite)
|
|
@@ -131,13 +133,6 @@ class SecurityDatabase:
|
|
| 131 |
cursor.close()
|
| 132 |
|
| 133 |
async def initialize(self) -> None:
|
| 134 |
-
"""Create ORM-mapped tables and indexes that do not exist yet.
|
| 135 |
-
|
| 136 |
-
SQLAlchemy create_all is intentionally additive: it does not alter
|
| 137 |
-
existing columns or repair incompatible foreign keys. PostgreSQL RLS
|
| 138 |
-
policies and migration-only constraints must still be applied by SQL
|
| 139 |
-
migrations and are verified separately at startup.
|
| 140 |
-
"""
|
| 141 |
if not self.auto_migrate:
|
| 142 |
return
|
| 143 |
async with self.engine.begin() as connection:
|
|
@@ -188,39 +183,35 @@ class SecurityDatabase:
|
|
| 188 |
tables = await connection.run_sync(lambda sync: set(inspect(sync).get_table_names()))
|
| 189 |
if not self.is_postgres:
|
| 190 |
indexes: set[str] = set()
|
|
|
|
| 191 |
else:
|
| 192 |
rows = await connection.execute(
|
| 193 |
text("select indexname from pg_indexes " "where schemaname = current_schema()")
|
| 194 |
)
|
| 195 |
indexes = set(rows.scalars().all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
missing = sorted(REQUIRED_SECURITY_TABLES - tables)
|
| 197 |
if self.is_postgres:
|
| 198 |
missing.extend(
|
| 199 |
f"index:{name}" for name in sorted(REQUIRED_POSTGRES_SECURITY_INDEXES - indexes)
|
| 200 |
)
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
async def missing_schema_columns(self) -> list[str]:
|
| 204 |
-
"""Report columns introduced by additive migrations that create_all cannot repair."""
|
| 205 |
-
required = {
|
| 206 |
-
"generation_requests": {"project_id", "product_surface"},
|
| 207 |
-
"generation_jobs": {"brand_kit_version_id", "progress_percent"},
|
| 208 |
-
"generation_job_attempts": {"error_code"},
|
| 209 |
-
}
|
| 210 |
-
async with self.engine.connect() as connection:
|
| 211 |
-
def inspect_columns(sync_connection):
|
| 212 |
-
inspector = inspect(sync_connection)
|
| 213 |
-
tables = set(inspector.get_table_names())
|
| 214 |
-
missing: list[str] = []
|
| 215 |
-
for table, expected in required.items():
|
| 216 |
-
if table not in tables:
|
| 217 |
-
continue # Missing tables are reported by missing_schema_objects().
|
| 218 |
-
columns = {column["name"] for column in inspector.get_columns(table)}
|
| 219 |
missing.extend(
|
| 220 |
-
f"column:{
|
|
|
|
|
|
|
|
|
|
| 221 |
)
|
| 222 |
-
|
| 223 |
-
return await connection.run_sync(inspect_columns)
|
| 224 |
|
| 225 |
async def missing_tables(self) -> list[str]:
|
| 226 |
async with self.engine.connect() as connection:
|
|
|
|
| 49 |
"marketplace_template_applications",
|
| 50 |
"brand_kits",
|
| 51 |
"brand_kit_versions",
|
| 52 |
+
"teams",
|
| 53 |
+
"team_members",
|
| 54 |
+
"invitations",
|
| 55 |
+
"project_collaborators",
|
| 56 |
+
"approval_workflows",
|
| 57 |
+
"approval_requests",
|
| 58 |
+
"review_comments",
|
| 59 |
+
"collaboration_activities",
|
| 60 |
+
"notification_preferences",
|
| 61 |
}
|
| 62 |
)
|
| 63 |
+
REQUIRED_POSTGRES_SECURITY_COLUMNS = {
|
| 64 |
+
"generation_jobs": frozenset({"brand_kit_version_id"}),
|
| 65 |
+
"generation_job_attempts": frozenset({"brand_kit_version_id", "external_job_id"}),
|
| 66 |
+
"invitations": frozenset({"token_hash", "created_at"}),
|
| 67 |
+
"collaboration_activities": frozenset({"created_at", "metadata"}),
|
| 68 |
+
}
|
| 69 |
REQUIRED_POSTGRES_SECURITY_INDEXES = frozenset(
|
| 70 |
{
|
| 71 |
"uq_generation_job_provider_external",
|
|
|
|
| 101 |
"uq_brand_kits_one_default",
|
| 102 |
"uq_brand_kit_version",
|
| 103 |
"ix_brand_versions_kit_created",
|
| 104 |
+
"ix_collaboration_activities_workspace_created",
|
| 105 |
+
"ix_notification_preferences_workspace_user",
|
| 106 |
}
|
| 107 |
)
|
| 108 |
|
|
|
|
| 114 |
database_url = normalize_async_database_url(database_url)
|
| 115 |
self.database_url = database_url
|
| 116 |
self.auto_migrate = auto_migrate or database_url.startswith("sqlite")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
self.engine: AsyncEngine = create_async_engine(
|
| 118 |
database_url,
|
| 119 |
pool_pre_ping=True,
|
|
|
|
| 120 |
)
|
| 121 |
if database_url.startswith("sqlite"):
|
| 122 |
event.listen(self.engine.sync_engine, "connect", self._configure_sqlite)
|
|
|
|
| 133 |
cursor.close()
|
| 134 |
|
| 135 |
async def initialize(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
if not self.auto_migrate:
|
| 137 |
return
|
| 138 |
async with self.engine.begin() as connection:
|
|
|
|
| 183 |
tables = await connection.run_sync(lambda sync: set(inspect(sync).get_table_names()))
|
| 184 |
if not self.is_postgres:
|
| 185 |
indexes: set[str] = set()
|
| 186 |
+
columns_by_table: dict[str, set[str]] = {}
|
| 187 |
else:
|
| 188 |
rows = await connection.execute(
|
| 189 |
text("select indexname from pg_indexes " "where schemaname = current_schema()")
|
| 190 |
)
|
| 191 |
indexes = set(rows.scalars().all())
|
| 192 |
+
column_rows = await connection.execute(
|
| 193 |
+
text(
|
| 194 |
+
"select table_name, column_name from information_schema.columns "
|
| 195 |
+
"where table_schema = current_schema()"
|
| 196 |
+
)
|
| 197 |
+
)
|
| 198 |
+
columns_by_table = {}
|
| 199 |
+
for table_name, column_name in column_rows:
|
| 200 |
+
columns_by_table.setdefault(str(table_name), set()).add(str(column_name))
|
| 201 |
missing = sorted(REQUIRED_SECURITY_TABLES - tables)
|
| 202 |
if self.is_postgres:
|
| 203 |
missing.extend(
|
| 204 |
f"index:{name}" for name in sorted(REQUIRED_POSTGRES_SECURITY_INDEXES - indexes)
|
| 205 |
)
|
| 206 |
+
for table_name, required_columns in REQUIRED_POSTGRES_SECURITY_COLUMNS.items():
|
| 207 |
+
if table_name in tables:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
missing.extend(
|
| 209 |
+
f"column:{table_name}.{column_name}"
|
| 210 |
+
for column_name in sorted(
|
| 211 |
+
required_columns - columns_by_table.get(table_name, set())
|
| 212 |
+
)
|
| 213 |
)
|
| 214 |
+
return missing
|
|
|
|
| 215 |
|
| 216 |
async def missing_tables(self) -> list[str]:
|
| 217 |
async with self.engine.connect() as connection:
|
app/security/migrations/0005_generation_brand_kit_snapshot_postgres.sql
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Persist the brand-kit version snapshot on the logical generation job and
|
| 2 |
+
-- every attempt. These columns are nullable for jobs created before brand-kit
|
| 3 |
+
-- selection was introduced; no FK is used so historical snapshots survive
|
| 4 |
+
-- brand-kit archival/deletion and this migration is independent of project
|
| 5 |
+
-- domain migration ordering.
|
| 6 |
+
alter table generation_jobs
|
| 7 |
+
add column if not exists brand_kit_version_id varchar(36);
|
| 8 |
+
|
| 9 |
+
alter table generation_job_attempts
|
| 10 |
+
add column if not exists brand_kit_version_id varchar(36);
|
| 11 |
+
|
| 12 |
+
-- The SQLAlchemy model already exposes this retry identity; the original
|
| 13 |
+
-- generation-domain migration omitted it.
|
| 14 |
+
alter table generation_job_attempts
|
| 15 |
+
add column if not exists external_job_id varchar(128);
|
app/security/scopes.py
CHANGED
|
@@ -89,7 +89,6 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = {
|
|
| 89 |
"jobs:cancel",
|
| 90 |
"assets:read",
|
| 91 |
"assets:write",
|
| 92 |
-
"assets:delete",
|
| 93 |
"mcp:read",
|
| 94 |
"mcp:execute",
|
| 95 |
"system:read",
|
|
@@ -250,3 +249,25 @@ def effective_scopes(
|
|
| 250 |
if "admin" in scopes:
|
| 251 |
return ALL_SCOPES
|
| 252 |
return frozenset(scopes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
"jobs:cancel",
|
| 90 |
"assets:read",
|
| 91 |
"assets:write",
|
|
|
|
| 92 |
"mcp:read",
|
| 93 |
"mcp:execute",
|
| 94 |
"system:read",
|
|
|
|
| 249 |
if "admin" in scopes:
|
| 250 |
return ALL_SCOPES
|
| 251 |
return frozenset(scopes)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def membership_role_scopes(
|
| 255 |
+
role: str | None,
|
| 256 |
+
overrides: Mapping[str, Sequence[str]] | None = None,
|
| 257 |
+
) -> frozenset[str]:
|
| 258 |
+
"""Return the tenant-membership permission ceiling.
|
| 259 |
+
|
| 260 |
+
Membership roles and API-key roles are separate vocabularies. Owners and
|
| 261 |
+
administrators receive the full membership ceiling; ordinary members and
|
| 262 |
+
service principals receive the operator ceiling; viewers remain read-only.
|
| 263 |
+
The API-key role/scopes are intersected with this ceiling at authorization.
|
| 264 |
+
"""
|
| 265 |
+
normalized = (role or "").strip().lower()
|
| 266 |
+
roles = configured_roles(overrides)
|
| 267 |
+
if normalized in {"owner", "admin"}:
|
| 268 |
+
return ALL_SCOPES
|
| 269 |
+
if normalized in {"member", "service"}:
|
| 270 |
+
return roles.get("operator", DEFAULT_ROLE_SCOPES["operator"])
|
| 271 |
+
if normalized == "viewer":
|
| 272 |
+
return roles.get("viewer", DEFAULT_ROLE_SCOPES["viewer"])
|
| 273 |
+
return frozenset()
|
app/security/service.py
CHANGED
|
@@ -22,7 +22,7 @@ from app.security.errors import (
|
|
| 22 |
)
|
| 23 |
from app.security.models import APIKey
|
| 24 |
from app.security.schemas import APIKeyCreate, APIKeyPatch
|
| 25 |
-
from app.security.scopes import configured_roles, effective_scopes
|
| 26 |
from app.security.tenancy import TenantService
|
| 27 |
|
| 28 |
KEY_PATTERN = re.compile(r"^mp_([a-z][a-z0-9]{1,15})_([A-Za-z0-9_-]{43,})$")
|
|
@@ -218,6 +218,9 @@ class APIKeyService:
|
|
| 218 |
user_id=principal.user_id,
|
| 219 |
membership_id=principal.membership_id,
|
| 220 |
membership_role=principal.membership_role,
|
|
|
|
|
|
|
|
|
|
| 221 |
)
|
| 222 |
|
| 223 |
@staticmethod
|
|
|
|
| 22 |
)
|
| 23 |
from app.security.models import APIKey
|
| 24 |
from app.security.schemas import APIKeyCreate, APIKeyPatch
|
| 25 |
+
from app.security.scopes import configured_roles, effective_scopes, membership_role_scopes
|
| 26 |
from app.security.tenancy import TenantService
|
| 27 |
|
| 28 |
KEY_PATTERN = re.compile(r"^mp_([a-z][a-z0-9]{1,15})_([A-Za-z0-9_-]{43,})$")
|
|
|
|
| 218 |
user_id=principal.user_id,
|
| 219 |
membership_id=principal.membership_id,
|
| 220 |
membership_role=principal.membership_role,
|
| 221 |
+
membership_scopes=membership_role_scopes(
|
| 222 |
+
principal.membership_role, self.settings.auth_role_scopes
|
| 223 |
+
),
|
| 224 |
)
|
| 225 |
|
| 226 |
@staticmethod
|
app/social/domain/retry.py
CHANGED
|
@@ -13,10 +13,16 @@ class RetryDecision:
|
|
| 13 |
delay_seconds: int = 0
|
| 14 |
|
| 15 |
|
| 16 |
-
def classify_retry(
|
|
|
|
|
|
|
| 17 |
delay = BACKOFF_SECONDS[min(max(attempt - 1, 0), len(BACKOFF_SECONDS) - 1)]
|
| 18 |
if network_error or status_code in RETRYABLE_STATUS_CODES:
|
| 19 |
return RetryDecision(True, delay_seconds=delay)
|
| 20 |
if status_code == 401:
|
| 21 |
-
return RetryDecision(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
return RetryDecision(False)
|
|
|
|
| 13 |
delay_seconds: int = 0
|
| 14 |
|
| 15 |
|
| 16 |
+
def classify_retry(
|
| 17 |
+
*, status_code: int | None = None, network_error: bool = False, attempt: int = 1
|
| 18 |
+
) -> RetryDecision:
|
| 19 |
delay = BACKOFF_SECONDS[min(max(attempt - 1, 0), len(BACKOFF_SECONDS) - 1)]
|
| 20 |
if network_error or status_code in RETRYABLE_STATUS_CODES:
|
| 21 |
return RetryDecision(True, delay_seconds=delay)
|
| 22 |
if status_code == 401:
|
| 23 |
+
return RetryDecision(
|
| 24 |
+
attempt <= 1,
|
| 25 |
+
refresh_token_first=attempt <= 1,
|
| 26 |
+
delay_seconds=0,
|
| 27 |
+
)
|
| 28 |
return RetryDecision(False)
|
app/social/providers/x.py
CHANGED
|
@@ -73,17 +73,13 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 73 |
self.settings = settings
|
| 74 |
self.client_id = settings.x_client_id.strip()
|
| 75 |
self._client_secret = (
|
| 76 |
-
settings.x_client_secret.get_secret_value()
|
| 77 |
-
if settings.x_client_secret
|
| 78 |
-
else ""
|
| 79 |
)
|
| 80 |
self.redirect_uri = settings.x_redirect_uri.strip()
|
| 81 |
self.configuration_ready = bool(
|
| 82 |
self.client_id and self._client_secret and self.redirect_uri
|
| 83 |
)
|
| 84 |
-
self.publishing_ready =
|
| 85 |
-
settings.x_publishing_enabled and self.configuration_ready
|
| 86 |
-
)
|
| 87 |
self.reconciliation_poll_seconds = settings.x_media_processing_poll_seconds
|
| 88 |
self._client = http_client or httpx.AsyncClient(
|
| 89 |
timeout=httpx.Timeout(settings.x_request_timeout_seconds),
|
|
@@ -100,9 +96,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 100 |
# Public post metrics use the tweet.read scope already requested by
|
| 101 |
# the normal connection flow. Phase 5C never broadens OAuth consent
|
| 102 |
# silently to restricted non-public, organic, or promoted metrics.
|
| 103 |
-
analytics_required_scopes=(
|
| 104 |
-
["tweet.read"] if self.configuration_ready else []
|
| 105 |
-
),
|
| 106 |
publishing_required_scopes=(
|
| 107 |
["tweet.write", "media.write"] if self.publishing_ready else []
|
| 108 |
),
|
|
@@ -236,9 +230,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 236 |
)
|
| 237 |
user_id = user.get("id")
|
| 238 |
if not isinstance(user_id, str) or _X_ID.fullmatch(user_id) is None:
|
| 239 |
-
raise SocialProviderUnavailableError(
|
| 240 |
-
"X returned an account without a stable user ID."
|
| 241 |
-
)
|
| 242 |
metadata = public_provider_data(
|
| 243 |
{
|
| 244 |
"x_user_id": user_id,
|
|
@@ -252,14 +244,8 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 252 |
return {
|
| 253 |
"external_account_id": user_id,
|
| 254 |
"account_type": "user",
|
| 255 |
-
"username": (
|
| 256 |
-
|
| 257 |
-
if isinstance(user.get("username"), str)
|
| 258 |
-
else None
|
| 259 |
-
),
|
| 260 |
-
"display_name": (
|
| 261 |
-
user.get("name") if isinstance(user.get("name"), str) else user_id
|
| 262 |
-
),
|
| 263 |
"avatar_url": (
|
| 264 |
user.get("profile_image_url")
|
| 265 |
if isinstance(user.get("profile_image_url"), str)
|
|
@@ -347,13 +333,9 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 347 |
or isinstance(frame_count, bool)
|
| 348 |
or frame_count <= 0
|
| 349 |
):
|
| 350 |
-
raise SocialMediaInvalidError(
|
| 351 |
-
"X animated GIF frame count is unavailable."
|
| 352 |
-
)
|
| 353 |
if frame_count > 350:
|
| 354 |
-
raise SocialMediaInvalidError(
|
| 355 |
-
"X animated GIFs must not exceed 350 frames."
|
| 356 |
-
)
|
| 357 |
if width * height * frame_count > 300_000_000:
|
| 358 |
raise SocialMediaInvalidError(
|
| 359 |
"X animated GIFs must not exceed 300 million total pixels."
|
|
@@ -371,11 +353,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 371 |
if duration < 0.5 or duration > _MAX_VIDEO_DURATION:
|
| 372 |
raise SocialMediaInvalidError("X video duration must be between 0.5 and 140 seconds.")
|
| 373 |
fps = probe.get("fps")
|
| 374 |
-
if (
|
| 375 |
-
not isinstance(fps, (int, float))
|
| 376 |
-
or isinstance(fps, bool)
|
| 377 |
-
or fps <= 0
|
| 378 |
-
):
|
| 379 |
raise SocialMediaInvalidError("X video frame rate is unavailable.")
|
| 380 |
if fps > 60:
|
| 381 |
raise SocialMediaInvalidError("X video frame rate must not exceed 60 FPS.")
|
|
@@ -383,15 +361,11 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 383 |
raise SocialMediaInvalidError("X video dimensions are outside the documented limits.")
|
| 384 |
aspect_ratio = width / height
|
| 385 |
if aspect_ratio < (1 / 3) or aspect_ratio > 3:
|
| 386 |
-
raise SocialMediaInvalidError(
|
| 387 |
-
"X video aspect ratio must be between 1:3 and 3:1."
|
| 388 |
-
)
|
| 389 |
pixel_format = str(streams[0].get("pixel_format") or "").lower()
|
| 390 |
if "420" not in pixel_format:
|
| 391 |
raise SocialMediaInvalidError("X video must use YUV 4:2:0 pixel format.")
|
| 392 |
-
sample_aspect_ratio = str(
|
| 393 |
-
streams[0].get("sample_aspect_ratio") or ""
|
| 394 |
-
).lower()
|
| 395 |
if sample_aspect_ratio != "1:1":
|
| 396 |
raise SocialMediaInvalidError("X video pixel aspect ratio must be 1:1.")
|
| 397 |
field_order = str(streams[0].get("field_order") or "").lower()
|
|
@@ -408,9 +382,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 408 |
"aac lc",
|
| 409 |
"low complexity",
|
| 410 |
}:
|
| 411 |
-
raise SocialMediaInvalidError(
|
| 412 |
-
"X video audio must use AAC Low Complexity."
|
| 413 |
-
)
|
| 414 |
channels = audio.get("channels")
|
| 415 |
if (
|
| 416 |
not isinstance(channels, int)
|
|
@@ -439,9 +411,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 439 |
mime_type = str(media.get("mime_type") or "").split(";", 1)[0].lower()
|
| 440 |
if mime_type in _IMAGE_MIME and mime_type != "image/gif":
|
| 441 |
return await self._upload_image(token, path, mime_type, state, persist)
|
| 442 |
-
media_category =
|
| 443 |
-
"tweet_gif" if mime_type == "image/gif" else "tweet_video"
|
| 444 |
-
)
|
| 445 |
return await self._upload_chunked_media(
|
| 446 |
token,
|
| 447 |
path,
|
|
@@ -460,9 +430,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 460 |
raise SocialPublishFailedError("Typed X post metadata is required.")
|
| 461 |
post = XPostMetadata.model_validate(metadata)
|
| 462 |
state = (
|
| 463 |
-
payload.get("provider_state")
|
| 464 |
-
if isinstance(payload.get("provider_state"), dict)
|
| 465 |
-
else {}
|
| 466 |
)
|
| 467 |
upload = payload.get("upload") if isinstance(payload.get("upload"), dict) else {}
|
| 468 |
media_ids = self._media_ids(upload, state)
|
|
@@ -531,9 +499,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 531 |
or not isinstance(data.get("id"), str)
|
| 532 |
or _X_ID.fullmatch(data["id"]) is None
|
| 533 |
):
|
| 534 |
-
raise SocialProviderUnavailableError(
|
| 535 |
-
"X returned an invalid created post response."
|
| 536 |
-
)
|
| 537 |
post_id = data["id"]
|
| 538 |
return {
|
| 539 |
"id": post_id,
|
|
@@ -549,18 +515,14 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 549 |
"""Find an X post accepted before the local target ID was persisted."""
|
| 550 |
|
| 551 |
state = (
|
| 552 |
-
payload.get("provider_state")
|
| 553 |
-
if isinstance(payload.get("provider_state"), dict)
|
| 554 |
-
else {}
|
| 555 |
)
|
| 556 |
submission_attempted = state.get("x_post_submission_attempted")
|
| 557 |
if submission_attempted is False:
|
| 558 |
# New jobs explicitly record that no Create Post request has been
|
| 559 |
# sent yet. Avoid an unnecessary timeline read on the first run.
|
| 560 |
return None
|
| 561 |
-
started = state.get("x_post_submission_started_at") or state.get(
|
| 562 |
-
"x_publish_started_at"
|
| 563 |
-
)
|
| 564 |
account_id = payload.get("provider_account_id")
|
| 565 |
metadata = payload.get("x_post_metadata")
|
| 566 |
if (
|
|
@@ -580,16 +542,14 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 580 |
started_at = started_at.replace(tzinfo=timezone.utc)
|
| 581 |
started_at = started_at.astimezone(timezone.utc)
|
| 582 |
media_keys = set(self._media_keys(payload.get("upload"), state))
|
| 583 |
-
expected_reply =
|
| 584 |
-
post.reply.in_reply_to_tweet_id if post.reply is not None else None
|
| 585 |
-
)
|
| 586 |
pagination_token: str | None = None
|
| 587 |
for page in range(10):
|
| 588 |
params = {
|
| 589 |
"max_results": "100",
|
| 590 |
-
"start_time": (
|
| 591 |
-
|
| 592 |
-
|
| 593 |
"tweet.fields": _X_POST_FIELDS,
|
| 594 |
"expansions": "attachments.media_keys",
|
| 595 |
"media.fields": _X_MEDIA_FIELDS,
|
|
@@ -617,9 +577,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 617 |
if not isinstance(created_at, str):
|
| 618 |
continue
|
| 619 |
try:
|
| 620 |
-
created = datetime.fromisoformat(
|
| 621 |
-
created_at.replace("Z", "+00:00")
|
| 622 |
-
)
|
| 623 |
except ValueError:
|
| 624 |
continue
|
| 625 |
if created.tzinfo is None:
|
|
@@ -643,14 +601,10 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 643 |
if expected_reply is None and reply_ids:
|
| 644 |
continue
|
| 645 |
attachments = (
|
| 646 |
-
item.get("attachments")
|
| 647 |
-
if isinstance(item.get("attachments"), dict)
|
| 648 |
-
else {}
|
| 649 |
)
|
| 650 |
item_keys = {
|
| 651 |
-
value
|
| 652 |
-
for value in attachments.get("media_keys", [])
|
| 653 |
-
if isinstance(value, str)
|
| 654 |
}
|
| 655 |
if media_keys != item_keys:
|
| 656 |
continue
|
|
@@ -665,9 +619,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 665 |
}
|
| 666 |
response_meta = payload_json.get("meta")
|
| 667 |
next_token = (
|
| 668 |
-
response_meta.get("next_token")
|
| 669 |
-
if isinstance(response_meta, dict)
|
| 670 |
-
else None
|
| 671 |
)
|
| 672 |
if not isinstance(next_token, str) or not next_token:
|
| 673 |
if submission_attempted is True:
|
|
@@ -717,9 +669,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 717 |
"metadata": public_provider_data(data),
|
| 718 |
}
|
| 719 |
|
| 720 |
-
async def get_metrics(
|
| 721 |
-
self, token: dict[str, Any], external_id: str
|
| 722 |
-
) -> dict[str, Any]:
|
| 723 |
"""Return only public metrics documented by the official X API v2.
|
| 724 |
|
| 725 |
X also defines non-public, organic, and promoted metric fields. They
|
|
@@ -756,9 +706,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 756 |
payload = self._response_payload(response, operation="post analytics")
|
| 757 |
data = payload.get("data")
|
| 758 |
if not isinstance(data, dict) or data.get("id") != external_id:
|
| 759 |
-
raise SocialProviderUnavailableError(
|
| 760 |
-
"X returned an invalid post analytics response."
|
| 761 |
-
)
|
| 762 |
public_metrics = data.get("public_metrics")
|
| 763 |
if not isinstance(public_metrics, dict):
|
| 764 |
return {
|
|
@@ -793,9 +741,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 793 |
view_counts: list[int] = []
|
| 794 |
if isinstance(media, list):
|
| 795 |
for item in media:
|
| 796 |
-
metrics = (
|
| 797 |
-
item.get("public_metrics") if isinstance(item, dict) else None
|
| 798 |
-
)
|
| 799 |
value = metrics.get("view_count") if isinstance(metrics, dict) else None
|
| 800 |
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
| 801 |
view_counts.append(value)
|
|
@@ -826,9 +772,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 826 |
payload = self._response_payload(response, operation="post deletion")
|
| 827 |
data = payload.get("data")
|
| 828 |
if not isinstance(data, dict) or data.get("deleted") is not True:
|
| 829 |
-
raise SocialProviderUnavailableError(
|
| 830 |
-
"X did not confirm that the post was deleted."
|
| 831 |
-
)
|
| 832 |
|
| 833 |
def publish_failure(self, result: dict[str, Any]) -> Exception:
|
| 834 |
if result.get("status") == "deleted":
|
|
@@ -923,14 +867,10 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 923 |
operation="media upload initialization",
|
| 924 |
invalid_media=True,
|
| 925 |
)
|
| 926 |
-
payload = self._response_payload(
|
| 927 |
-
response, operation="media upload initialization"
|
| 928 |
-
)
|
| 929 |
data = self._media_data(payload, operation="media upload initialization")
|
| 930 |
media_id = data["id"]
|
| 931 |
-
media_key = self._response_media_key(
|
| 932 |
-
data, operation="media upload initialization"
|
| 933 |
-
)
|
| 934 |
state = {
|
| 935 |
**init_state,
|
| 936 |
"x_media_id": media_id,
|
|
@@ -999,9 +939,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 999 |
state["x_media_finalized"] = True
|
| 1000 |
if persist:
|
| 1001 |
await persist(state)
|
| 1002 |
-
await self._wait_for_media_processing(
|
| 1003 |
-
token, media_id, heartbeat=heartbeat
|
| 1004 |
-
)
|
| 1005 |
state = {
|
| 1006 |
**state,
|
| 1007 |
"x_media_id": media_id,
|
|
@@ -1051,15 +989,11 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1051 |
)
|
| 1052 |
if heartbeat:
|
| 1053 |
await heartbeat()
|
| 1054 |
-
remaining = max(
|
| 1055 |
-
0.0, (deadline - datetime.now(timezone.utc)).total_seconds()
|
| 1056 |
-
)
|
| 1057 |
await asyncio.sleep(min(max(seconds, 1), remaining))
|
| 1058 |
raise SocialProviderUnavailableError("X media processing did not complete in time.")
|
| 1059 |
|
| 1060 |
-
async def _token_request(
|
| 1061 |
-
self, form: dict[str, str], *, operation: str
|
| 1062 |
-
) -> dict[str, Any]:
|
| 1063 |
try:
|
| 1064 |
response = await self._client.post(
|
| 1065 |
_X_TOKEN_ENDPOINT,
|
|
@@ -1113,9 +1047,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1113 |
operation: str,
|
| 1114 |
**kwargs: Any,
|
| 1115 |
) -> dict[str, Any]:
|
| 1116 |
-
response = await self._authorized_request(
|
| 1117 |
-
method, url, token, operation=operation, **kwargs
|
| 1118 |
-
)
|
| 1119 |
return self._response_payload(response, operation=operation)
|
| 1120 |
|
| 1121 |
def _ensure_configured(self) -> None:
|
|
@@ -1144,9 +1076,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1144 |
f"X returned an invalid {operation} response."
|
| 1145 |
) from exc
|
| 1146 |
if not isinstance(payload, dict):
|
| 1147 |
-
raise SocialProviderUnavailableError(
|
| 1148 |
-
f"X returned an invalid {operation} response."
|
| 1149 |
-
)
|
| 1150 |
return payload
|
| 1151 |
|
| 1152 |
@classmethod
|
|
@@ -1191,10 +1121,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1191 |
if not values:
|
| 1192 |
value = state.get("x_media_id")
|
| 1193 |
values = [value] if isinstance(value, str) else []
|
| 1194 |
-
if any(
|
| 1195 |
-
not isinstance(value, str) or _X_ID.fullmatch(value) is None
|
| 1196 |
-
for value in values
|
| 1197 |
-
):
|
| 1198 |
raise SocialMediaInvalidError("X returned an invalid media ID.")
|
| 1199 |
result = list(values)
|
| 1200 |
if len(result) > 1:
|
|
@@ -1232,13 +1159,9 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1232 |
if isinstance(error, str):
|
| 1233 |
error_code = error.lower()
|
| 1234 |
elif isinstance(error, dict):
|
| 1235 |
-
error_code = str(
|
| 1236 |
-
error.get("code") or error.get("type") or ""
|
| 1237 |
-
).lower()
|
| 1238 |
if not error_code:
|
| 1239 |
-
error_code = str(
|
| 1240 |
-
payload.get("type") or payload.get("title") or ""
|
| 1241 |
-
).lower()
|
| 1242 |
errors = payload.get("errors")
|
| 1243 |
if (
|
| 1244 |
not error_code
|
|
@@ -1246,9 +1169,7 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1246 |
and errors
|
| 1247 |
and isinstance(errors[0], dict)
|
| 1248 |
):
|
| 1249 |
-
error_code = str(
|
| 1250 |
-
errors[0].get("type") or errors[0].get("title") or ""
|
| 1251 |
-
).lower()
|
| 1252 |
if error_code in {"invalid_client", "unauthorized_client"}:
|
| 1253 |
raise SocialProviderUnavailableError("X OAuth client authentication failed.")
|
| 1254 |
if response.status_code == 401 or error_code in {
|
|
@@ -1270,14 +1191,10 @@ class XProvider(OAuthFoundationAdapter):
|
|
| 1270 |
if response.status_code in {400, 409, 413, 415, 422}:
|
| 1271 |
if operation == "authorization code exchange":
|
| 1272 |
raise SocialReauthRequiredError("The X authorization code is invalid or expired.")
|
| 1273 |
-
if
|
| 1274 |
-
invalid_media
|
| 1275 |
-
or "media" in operation
|
| 1276 |
-
or response.status_code in {413, 415}
|
| 1277 |
-
):
|
| 1278 |
raise SocialMediaInvalidError("X rejected the supplied media.")
|
| 1279 |
raise SocialPublishFailedError(f"X rejected the supplied {operation} request.")
|
| 1280 |
-
if response.status_code == 404 and "media" in operation:
|
| 1281 |
raise SocialCapabilityUnsupportedError(
|
| 1282 |
"X media upload is unavailable for this application's access tier."
|
| 1283 |
)
|
|
|
|
| 73 |
self.settings = settings
|
| 74 |
self.client_id = settings.x_client_id.strip()
|
| 75 |
self._client_secret = (
|
| 76 |
+
settings.x_client_secret.get_secret_value() if settings.x_client_secret else ""
|
|
|
|
|
|
|
| 77 |
)
|
| 78 |
self.redirect_uri = settings.x_redirect_uri.strip()
|
| 79 |
self.configuration_ready = bool(
|
| 80 |
self.client_id and self._client_secret and self.redirect_uri
|
| 81 |
)
|
| 82 |
+
self.publishing_ready = settings.x_publishing_enabled and self.configuration_ready
|
|
|
|
|
|
|
| 83 |
self.reconciliation_poll_seconds = settings.x_media_processing_poll_seconds
|
| 84 |
self._client = http_client or httpx.AsyncClient(
|
| 85 |
timeout=httpx.Timeout(settings.x_request_timeout_seconds),
|
|
|
|
| 96 |
# Public post metrics use the tweet.read scope already requested by
|
| 97 |
# the normal connection flow. Phase 5C never broadens OAuth consent
|
| 98 |
# silently to restricted non-public, organic, or promoted metrics.
|
| 99 |
+
analytics_required_scopes=(["tweet.read"] if self.configuration_ready else []),
|
|
|
|
|
|
|
| 100 |
publishing_required_scopes=(
|
| 101 |
["tweet.write", "media.write"] if self.publishing_ready else []
|
| 102 |
),
|
|
|
|
| 230 |
)
|
| 231 |
user_id = user.get("id")
|
| 232 |
if not isinstance(user_id, str) or _X_ID.fullmatch(user_id) is None:
|
| 233 |
+
raise SocialProviderUnavailableError("X returned an account without a stable user ID.")
|
|
|
|
|
|
|
| 234 |
metadata = public_provider_data(
|
| 235 |
{
|
| 236 |
"x_user_id": user_id,
|
|
|
|
| 244 |
return {
|
| 245 |
"external_account_id": user_id,
|
| 246 |
"account_type": "user",
|
| 247 |
+
"username": (user.get("username") if isinstance(user.get("username"), str) else None),
|
| 248 |
+
"display_name": (user.get("name") if isinstance(user.get("name"), str) else user_id),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
"avatar_url": (
|
| 250 |
user.get("profile_image_url")
|
| 251 |
if isinstance(user.get("profile_image_url"), str)
|
|
|
|
| 333 |
or isinstance(frame_count, bool)
|
| 334 |
or frame_count <= 0
|
| 335 |
):
|
| 336 |
+
raise SocialMediaInvalidError("X animated GIF frame count is unavailable.")
|
|
|
|
|
|
|
| 337 |
if frame_count > 350:
|
| 338 |
+
raise SocialMediaInvalidError("X animated GIFs must not exceed 350 frames.")
|
|
|
|
|
|
|
| 339 |
if width * height * frame_count > 300_000_000:
|
| 340 |
raise SocialMediaInvalidError(
|
| 341 |
"X animated GIFs must not exceed 300 million total pixels."
|
|
|
|
| 353 |
if duration < 0.5 or duration > _MAX_VIDEO_DURATION:
|
| 354 |
raise SocialMediaInvalidError("X video duration must be between 0.5 and 140 seconds.")
|
| 355 |
fps = probe.get("fps")
|
| 356 |
+
if not isinstance(fps, (int, float)) or isinstance(fps, bool) or fps <= 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
raise SocialMediaInvalidError("X video frame rate is unavailable.")
|
| 358 |
if fps > 60:
|
| 359 |
raise SocialMediaInvalidError("X video frame rate must not exceed 60 FPS.")
|
|
|
|
| 361 |
raise SocialMediaInvalidError("X video dimensions are outside the documented limits.")
|
| 362 |
aspect_ratio = width / height
|
| 363 |
if aspect_ratio < (1 / 3) or aspect_ratio > 3:
|
| 364 |
+
raise SocialMediaInvalidError("X video aspect ratio must be between 1:3 and 3:1.")
|
|
|
|
|
|
|
| 365 |
pixel_format = str(streams[0].get("pixel_format") or "").lower()
|
| 366 |
if "420" not in pixel_format:
|
| 367 |
raise SocialMediaInvalidError("X video must use YUV 4:2:0 pixel format.")
|
| 368 |
+
sample_aspect_ratio = str(streams[0].get("sample_aspect_ratio") or "").lower()
|
|
|
|
|
|
|
| 369 |
if sample_aspect_ratio != "1:1":
|
| 370 |
raise SocialMediaInvalidError("X video pixel aspect ratio must be 1:1.")
|
| 371 |
field_order = str(streams[0].get("field_order") or "").lower()
|
|
|
|
| 382 |
"aac lc",
|
| 383 |
"low complexity",
|
| 384 |
}:
|
| 385 |
+
raise SocialMediaInvalidError("X video audio must use AAC Low Complexity.")
|
|
|
|
|
|
|
| 386 |
channels = audio.get("channels")
|
| 387 |
if (
|
| 388 |
not isinstance(channels, int)
|
|
|
|
| 411 |
mime_type = str(media.get("mime_type") or "").split(";", 1)[0].lower()
|
| 412 |
if mime_type in _IMAGE_MIME and mime_type != "image/gif":
|
| 413 |
return await self._upload_image(token, path, mime_type, state, persist)
|
| 414 |
+
media_category = "tweet_gif" if mime_type == "image/gif" else "tweet_video"
|
|
|
|
|
|
|
| 415 |
return await self._upload_chunked_media(
|
| 416 |
token,
|
| 417 |
path,
|
|
|
|
| 430 |
raise SocialPublishFailedError("Typed X post metadata is required.")
|
| 431 |
post = XPostMetadata.model_validate(metadata)
|
| 432 |
state = (
|
| 433 |
+
payload.get("provider_state") if isinstance(payload.get("provider_state"), dict) else {}
|
|
|
|
|
|
|
| 434 |
)
|
| 435 |
upload = payload.get("upload") if isinstance(payload.get("upload"), dict) else {}
|
| 436 |
media_ids = self._media_ids(upload, state)
|
|
|
|
| 499 |
or not isinstance(data.get("id"), str)
|
| 500 |
or _X_ID.fullmatch(data["id"]) is None
|
| 501 |
):
|
| 502 |
+
raise SocialProviderUnavailableError("X returned an invalid created post response.")
|
|
|
|
|
|
|
| 503 |
post_id = data["id"]
|
| 504 |
return {
|
| 505 |
"id": post_id,
|
|
|
|
| 515 |
"""Find an X post accepted before the local target ID was persisted."""
|
| 516 |
|
| 517 |
state = (
|
| 518 |
+
payload.get("provider_state") if isinstance(payload.get("provider_state"), dict) else {}
|
|
|
|
|
|
|
| 519 |
)
|
| 520 |
submission_attempted = state.get("x_post_submission_attempted")
|
| 521 |
if submission_attempted is False:
|
| 522 |
# New jobs explicitly record that no Create Post request has been
|
| 523 |
# sent yet. Avoid an unnecessary timeline read on the first run.
|
| 524 |
return None
|
| 525 |
+
started = state.get("x_post_submission_started_at") or state.get("x_publish_started_at")
|
|
|
|
|
|
|
| 526 |
account_id = payload.get("provider_account_id")
|
| 527 |
metadata = payload.get("x_post_metadata")
|
| 528 |
if (
|
|
|
|
| 542 |
started_at = started_at.replace(tzinfo=timezone.utc)
|
| 543 |
started_at = started_at.astimezone(timezone.utc)
|
| 544 |
media_keys = set(self._media_keys(payload.get("upload"), state))
|
| 545 |
+
expected_reply = post.reply.in_reply_to_tweet_id if post.reply is not None else None
|
|
|
|
|
|
|
| 546 |
pagination_token: str | None = None
|
| 547 |
for page in range(10):
|
| 548 |
params = {
|
| 549 |
"max_results": "100",
|
| 550 |
+
"start_time": (started_at - timedelta(seconds=5))
|
| 551 |
+
.isoformat()
|
| 552 |
+
.replace("+00:00", "Z"),
|
| 553 |
"tweet.fields": _X_POST_FIELDS,
|
| 554 |
"expansions": "attachments.media_keys",
|
| 555 |
"media.fields": _X_MEDIA_FIELDS,
|
|
|
|
| 577 |
if not isinstance(created_at, str):
|
| 578 |
continue
|
| 579 |
try:
|
| 580 |
+
created = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
|
|
|
|
|
|
|
| 581 |
except ValueError:
|
| 582 |
continue
|
| 583 |
if created.tzinfo is None:
|
|
|
|
| 601 |
if expected_reply is None and reply_ids:
|
| 602 |
continue
|
| 603 |
attachments = (
|
| 604 |
+
item.get("attachments") if isinstance(item.get("attachments"), dict) else {}
|
|
|
|
|
|
|
| 605 |
)
|
| 606 |
item_keys = {
|
| 607 |
+
value for value in attachments.get("media_keys", []) if isinstance(value, str)
|
|
|
|
|
|
|
| 608 |
}
|
| 609 |
if media_keys != item_keys:
|
| 610 |
continue
|
|
|
|
| 619 |
}
|
| 620 |
response_meta = payload_json.get("meta")
|
| 621 |
next_token = (
|
| 622 |
+
response_meta.get("next_token") if isinstance(response_meta, dict) else None
|
|
|
|
|
|
|
| 623 |
)
|
| 624 |
if not isinstance(next_token, str) or not next_token:
|
| 625 |
if submission_attempted is True:
|
|
|
|
| 669 |
"metadata": public_provider_data(data),
|
| 670 |
}
|
| 671 |
|
| 672 |
+
async def get_metrics(self, token: dict[str, Any], external_id: str) -> dict[str, Any]:
|
|
|
|
|
|
|
| 673 |
"""Return only public metrics documented by the official X API v2.
|
| 674 |
|
| 675 |
X also defines non-public, organic, and promoted metric fields. They
|
|
|
|
| 706 |
payload = self._response_payload(response, operation="post analytics")
|
| 707 |
data = payload.get("data")
|
| 708 |
if not isinstance(data, dict) or data.get("id") != external_id:
|
| 709 |
+
raise SocialProviderUnavailableError("X returned an invalid post analytics response.")
|
|
|
|
|
|
|
| 710 |
public_metrics = data.get("public_metrics")
|
| 711 |
if not isinstance(public_metrics, dict):
|
| 712 |
return {
|
|
|
|
| 741 |
view_counts: list[int] = []
|
| 742 |
if isinstance(media, list):
|
| 743 |
for item in media:
|
| 744 |
+
metrics = item.get("public_metrics") if isinstance(item, dict) else None
|
|
|
|
|
|
|
| 745 |
value = metrics.get("view_count") if isinstance(metrics, dict) else None
|
| 746 |
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
| 747 |
view_counts.append(value)
|
|
|
|
| 772 |
payload = self._response_payload(response, operation="post deletion")
|
| 773 |
data = payload.get("data")
|
| 774 |
if not isinstance(data, dict) or data.get("deleted") is not True:
|
| 775 |
+
raise SocialProviderUnavailableError("X did not confirm that the post was deleted.")
|
|
|
|
|
|
|
| 776 |
|
| 777 |
def publish_failure(self, result: dict[str, Any]) -> Exception:
|
| 778 |
if result.get("status") == "deleted":
|
|
|
|
| 867 |
operation="media upload initialization",
|
| 868 |
invalid_media=True,
|
| 869 |
)
|
| 870 |
+
payload = self._response_payload(response, operation="media upload initialization")
|
|
|
|
|
|
|
| 871 |
data = self._media_data(payload, operation="media upload initialization")
|
| 872 |
media_id = data["id"]
|
| 873 |
+
media_key = self._response_media_key(data, operation="media upload initialization")
|
|
|
|
|
|
|
| 874 |
state = {
|
| 875 |
**init_state,
|
| 876 |
"x_media_id": media_id,
|
|
|
|
| 939 |
state["x_media_finalized"] = True
|
| 940 |
if persist:
|
| 941 |
await persist(state)
|
| 942 |
+
await self._wait_for_media_processing(token, media_id, heartbeat=heartbeat)
|
|
|
|
|
|
|
| 943 |
state = {
|
| 944 |
**state,
|
| 945 |
"x_media_id": media_id,
|
|
|
|
| 989 |
)
|
| 990 |
if heartbeat:
|
| 991 |
await heartbeat()
|
| 992 |
+
remaining = max(0.0, (deadline - datetime.now(timezone.utc)).total_seconds())
|
|
|
|
|
|
|
| 993 |
await asyncio.sleep(min(max(seconds, 1), remaining))
|
| 994 |
raise SocialProviderUnavailableError("X media processing did not complete in time.")
|
| 995 |
|
| 996 |
+
async def _token_request(self, form: dict[str, str], *, operation: str) -> dict[str, Any]:
|
|
|
|
|
|
|
| 997 |
try:
|
| 998 |
response = await self._client.post(
|
| 999 |
_X_TOKEN_ENDPOINT,
|
|
|
|
| 1047 |
operation: str,
|
| 1048 |
**kwargs: Any,
|
| 1049 |
) -> dict[str, Any]:
|
| 1050 |
+
response = await self._authorized_request(method, url, token, operation=operation, **kwargs)
|
|
|
|
|
|
|
| 1051 |
return self._response_payload(response, operation=operation)
|
| 1052 |
|
| 1053 |
def _ensure_configured(self) -> None:
|
|
|
|
| 1076 |
f"X returned an invalid {operation} response."
|
| 1077 |
) from exc
|
| 1078 |
if not isinstance(payload, dict):
|
| 1079 |
+
raise SocialProviderUnavailableError(f"X returned an invalid {operation} response.")
|
|
|
|
|
|
|
| 1080 |
return payload
|
| 1081 |
|
| 1082 |
@classmethod
|
|
|
|
| 1121 |
if not values:
|
| 1122 |
value = state.get("x_media_id")
|
| 1123 |
values = [value] if isinstance(value, str) else []
|
| 1124 |
+
if any(not isinstance(value, str) or _X_ID.fullmatch(value) is None for value in values):
|
|
|
|
|
|
|
|
|
|
| 1125 |
raise SocialMediaInvalidError("X returned an invalid media ID.")
|
| 1126 |
result = list(values)
|
| 1127 |
if len(result) > 1:
|
|
|
|
| 1159 |
if isinstance(error, str):
|
| 1160 |
error_code = error.lower()
|
| 1161 |
elif isinstance(error, dict):
|
| 1162 |
+
error_code = str(error.get("code") or error.get("type") or "").lower()
|
|
|
|
|
|
|
| 1163 |
if not error_code:
|
| 1164 |
+
error_code = str(payload.get("type") or payload.get("title") or "").lower()
|
|
|
|
|
|
|
| 1165 |
errors = payload.get("errors")
|
| 1166 |
if (
|
| 1167 |
not error_code
|
|
|
|
| 1169 |
and errors
|
| 1170 |
and isinstance(errors[0], dict)
|
| 1171 |
):
|
| 1172 |
+
error_code = str(errors[0].get("type") or errors[0].get("title") or "").lower()
|
|
|
|
|
|
|
| 1173 |
if error_code in {"invalid_client", "unauthorized_client"}:
|
| 1174 |
raise SocialProviderUnavailableError("X OAuth client authentication failed.")
|
| 1175 |
if response.status_code == 401 or error_code in {
|
|
|
|
| 1191 |
if response.status_code in {400, 409, 413, 415, 422}:
|
| 1192 |
if operation == "authorization code exchange":
|
| 1193 |
raise SocialReauthRequiredError("The X authorization code is invalid or expired.")
|
| 1194 |
+
if invalid_media or "media" in operation or response.status_code in {413, 415}:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1195 |
raise SocialMediaInvalidError("X rejected the supplied media.")
|
| 1196 |
raise SocialPublishFailedError(f"X rejected the supplied {operation} request.")
|
| 1197 |
+
if response.status_code == 404 and (invalid_media or "media" in operation):
|
| 1198 |
raise SocialCapabilityUnsupportedError(
|
| 1199 |
"X media upload is unavailable for this application's access tier."
|
| 1200 |
)
|
app/social/services/publishing_service.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.social.domain.errors import (
|
|
| 11 |
SocialCapabilityUnsupportedError,
|
| 12 |
SocialError,
|
| 13 |
SocialIdempotencyConflictError,
|
|
|
|
| 14 |
SocialPermissionDeniedError,
|
| 15 |
SocialPublishFailedError,
|
| 16 |
)
|
|
@@ -34,6 +35,7 @@ from app.social.services.media_asset_service import SocialMediaAssetService
|
|
| 34 |
from app.social.services.oauth_service import OAuthService
|
| 35 |
from app.brand.services.brand_service import BrandKitService
|
| 36 |
|
|
|
|
| 37 |
def _fingerprint(payload: SocialPostCreate) -> str:
|
| 38 |
encoded = json.dumps(
|
| 39 |
payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":")
|
|
@@ -228,22 +230,26 @@ class PublishingService:
|
|
| 228 |
for account in account_records:
|
| 229 |
await self._assert_publish_authorized(workspace_id, account)
|
| 230 |
|
| 231 |
-
#
|
| 232 |
-
#
|
| 233 |
-
|
| 234 |
-
# Check approval
|
| 235 |
-
if payload.project_id:
|
| 236 |
-
# Need to find approval workflow and check approval
|
| 237 |
-
# Adding this check:
|
| 238 |
-
# workflow = await self.repository.get_workflow_for_project(payload.project_id)
|
| 239 |
-
# if workflow:
|
| 240 |
-
# request = await self.repository.get_latest_request(workflow.id)
|
| 241 |
-
# if not request or request.status != 'approved':
|
| 242 |
-
# raise Exception("Approval required before publishing.")
|
| 243 |
-
pass
|
| 244 |
-
|
| 245 |
post = SocialPost(
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
)
|
| 248 |
targets = [
|
| 249 |
SocialPostTarget(
|
|
@@ -297,7 +303,13 @@ class PublishingService:
|
|
| 297 |
validation = await self.validate_post_targets(workspace_id, post_id)
|
| 298 |
if not validation.valid:
|
| 299 |
first = next(issue for target in validation.targets for issue in target.errors)
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
post, targets = await self.posts.get(workspace_id, post_id)
|
| 302 |
if post.media_asset_id is not None:
|
| 303 |
await self.media_assets.assert_owned_and_readable(workspace_id, post.media_asset_id)
|
|
|
|
| 11 |
SocialCapabilityUnsupportedError,
|
| 12 |
SocialError,
|
| 13 |
SocialIdempotencyConflictError,
|
| 14 |
+
SocialMediaInvalidError,
|
| 15 |
SocialPermissionDeniedError,
|
| 16 |
SocialPublishFailedError,
|
| 17 |
)
|
|
|
|
| 35 |
from app.social.services.oauth_service import OAuthService
|
| 36 |
from app.brand.services.brand_service import BrandKitService
|
| 37 |
|
| 38 |
+
|
| 39 |
def _fingerprint(payload: SocialPostCreate) -> str:
|
| 40 |
encoded = json.dumps(
|
| 41 |
payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":")
|
|
|
|
| 230 |
for account in account_records:
|
| 231 |
await self._assert_publish_authorized(workspace_id, account)
|
| 232 |
|
| 233 |
+
# Persist the complete tenant-owned publishing draft. Workspace ownership
|
| 234 |
+
# is mandatory and is used by PostRepository for both the insert session
|
| 235 |
+
# and all subsequent reads. Never derive it from caller metadata.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
post = SocialPost(
|
| 237 |
+
workspace_id=workspace_id,
|
| 238 |
+
project_id=payload.project_id,
|
| 239 |
+
campaign_id=payload.campaign_id,
|
| 240 |
+
media_asset_id=payload.media_asset_id,
|
| 241 |
+
canonical_caption=payload.caption,
|
| 242 |
+
canonical_hashtags=list(payload.hashtags),
|
| 243 |
+
# Brand-kit selection is not yet part of SocialPostCreate's public
|
| 244 |
+
# request contract; do not infer a version from untrusted metadata.
|
| 245 |
+
brand_kit_version_id=None,
|
| 246 |
+
source_variant_id=payload.source_variant_id,
|
| 247 |
+
status=PostStatus.DRAFT.value,
|
| 248 |
+
publish_mode=payload.publish_mode.value,
|
| 249 |
+
idempotency_key=idempotency_key,
|
| 250 |
+
request_fingerprint=fingerprint,
|
| 251 |
+
metadata_json=dict(payload.metadata),
|
| 252 |
+
created_by=user_id,
|
| 253 |
)
|
| 254 |
targets = [
|
| 255 |
SocialPostTarget(
|
|
|
|
| 303 |
validation = await self.validate_post_targets(workspace_id, post_id)
|
| 304 |
if not validation.valid:
|
| 305 |
first = next(issue for target in validation.targets for issue in target.errors)
|
| 306 |
+
error_type = {
|
| 307 |
+
SocialMediaInvalidError.code: SocialMediaInvalidError,
|
| 308 |
+
SocialAccountDisconnectedError.code: SocialAccountDisconnectedError,
|
| 309 |
+
SocialCapabilityUnsupportedError.code: SocialCapabilityUnsupportedError,
|
| 310 |
+
SocialPermissionDeniedError.code: SocialPermissionDeniedError,
|
| 311 |
+
}.get(first.code, SocialPublishFailedError)
|
| 312 |
+
raise error_type(first.message)
|
| 313 |
post, targets = await self.posts.get(workspace_id, post_id)
|
| 314 |
if post.media_asset_id is not None:
|
| 315 |
await self.media_assets.assert_owned_and_readable(workspace_id, post.media_asset_id)
|
docs/audit/CI_BASELINE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CI baseline and deferred lint debt
|
| 2 |
+
|
| 3 |
+
The backend currently has a broad Ruff rule selection that reports a large backlog of pre-existing import-order, modernization, and bugbear diagnostics. The initial PR CI run found 177 diagnostics across `I`, `UP`, and `B` rules. Those findings are retained as a visible, non-blocking report while the team remediates them in controlled batches.
|
| 4 |
+
|
| 5 |
+
The blocking Ruff gate is limited to `E`, `F`, and `ASYNC`, which checks core Python errors, undefined names, and async misuse. The workflow separately runs `ruff check --select I,UP,B --exit-zero` so the remaining diagnostics stay visible in CI instead of being silently discarded.
|
| 6 |
+
|
| 7 |
+
This is an interim baseline, not a claim that the style backlog is resolved. The backend CI enforces Black on changed Python files and runs pytest with the repository root explicitly on `PYTHONPATH`. Both remain blocking gates. Each deferred rule family should be tightened again as its existing violations are corrected.
|
docs/production-deployment-gate.md
CHANGED
|
@@ -72,18 +72,29 @@ in this dependency order before starting the Space:
|
|
| 72 |
3. `app/projects/migrations/0001_projects_foundation.sql`
|
| 73 |
4. `app/security/migrations/0003_generation_domain_postgres.sql`
|
| 74 |
5. `app/security/migrations/0004_generation_provider_runtime_postgres.sql`
|
| 75 |
-
6. `app/
|
| 76 |
-
7. `app/projects/migrations/
|
| 77 |
-
8. `app/projects/migrations/
|
| 78 |
-
9. `app/projects/migrations/
|
| 79 |
-
10. `app/projects/migrations/
|
| 80 |
-
11. `app/
|
| 81 |
-
12. `app/
|
| 82 |
-
13. `app/
|
| 83 |
-
14. `app/
|
| 84 |
-
15. `app/
|
| 85 |
-
16. `app/
|
| 86 |
-
17. `app/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
Keep `SECURITY_AUTO_MIGRATE=false` and `SOCIAL_AUTO_MIGRATE=false`. The
|
| 89 |
security startup gate checks tables and safety indexes. It then verifies the
|
|
|
|
| 72 |
3. `app/projects/migrations/0001_projects_foundation.sql`
|
| 73 |
4. `app/security/migrations/0003_generation_domain_postgres.sql`
|
| 74 |
5. `app/security/migrations/0004_generation_provider_runtime_postgres.sql`
|
| 75 |
+
6. `app/security/migrations/0005_generation_brand_kit_snapshot_postgres.sql`
|
| 76 |
+
7. `app/projects/migrations/0002_project_resources.sql`
|
| 77 |
+
8. `app/projects/migrations/0003_editor_persistence_rendering.sql`
|
| 78 |
+
9. `app/projects/migrations/0004_ai_studio.sql`
|
| 79 |
+
10. `app/projects/migrations/0005_ai_copilot.sql`
|
| 80 |
+
11. `app/projects/migrations/0006_template_marketplace.sql`
|
| 81 |
+
12. `app/projects/migrations/0008_brand_kits.sql`
|
| 82 |
+
13. `app/projects/migrations/0009_brand_kits_explicit_columns.sql`
|
| 83 |
+
14. `app/projects/migrations/0010_collaboration_foundation.sql`
|
| 84 |
+
15. `app/projects/migrations/0011_collaboration_enhancements.sql`
|
| 85 |
+
16. `app/projects/migrations/0012_approval_revision_awareness.sql`
|
| 86 |
+
17. `app/projects/migrations/0013_notification_preferences.sql`
|
| 87 |
+
18. `app/projects/migrations/0014_collaboration_model_alignment.sql`
|
| 88 |
+
19. `app/social/migrations/0001_social_foundation_postgres.sql`
|
| 89 |
+
20. `app/social/migrations/0002_social_rls.sql`
|
| 90 |
+
21. `app/social/migrations/0003_social_integrity_postgres.sql`
|
| 91 |
+
22. `app/social/migrations/0004_youtube_media_assets.sql`
|
| 92 |
+
23. `app/social/migrations/0005_x_text_posts.sql`
|
| 93 |
+
24. `app/social/migrations/0006_linkedin_oauth_account_type.sql`
|
| 94 |
+
25. `app/social/migrations/0007_tenant_asset_and_rls_hardening.sql`
|
| 95 |
+
26. `app/social/migrations/0008_unified_publishing.sql`
|
| 96 |
+
27. `app/social/migrations/0009_publishing_operations.sql`
|
| 97 |
+
28. `app/social/migrations/0010_analytics_insights.sql`
|
| 98 |
|
| 99 |
Keep `SECURITY_AUTO_MIGRATE=false` and `SOCIAL_AUTO_MIGRATE=false`. The
|
| 100 |
security startup gate checks tables and safety indexes. It then verifies the
|
docs/social-tenant-foundation.md
CHANGED
|
@@ -44,6 +44,22 @@ ownership record remains.
|
|
| 44 |
create canonical ownership records and therefore must not be used for
|
| 45 |
multi-tenant production social publishing.
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
## PostgreSQL role separation
|
| 48 |
|
| 49 |
Apply all security and social migrations with a controlled administrative
|
|
|
|
| 44 |
create canonical ownership records and therefore must not be used for
|
| 45 |
multi-tenant production social publishing.
|
| 46 |
|
| 47 |
+
## API-key and workspace membership authorization
|
| 48 |
+
|
| 49 |
+
An API key's effective scopes are the credential ceiling; the resolved active
|
| 50 |
+
workspace membership is a second, independent ceiling. Authorization requires
|
| 51 |
+
both to allow the requested scope. A privileged API key therefore cannot
|
| 52 |
+
elevate a viewer membership.
|
| 53 |
+
|
| 54 |
+
Workspace membership roles map to permission ceilings as follows:
|
| 55 |
+
|
| 56 |
+
- `owner` and `admin`: all membership scopes, still limited by the API key.
|
| 57 |
+
- `member` and `service`: the configured `operator` scope set.
|
| 58 |
+
- `viewer`: the configured read-only `viewer` scope set.
|
| 59 |
+
|
| 60 |
+
The role mapping is applied from the server-resolved `api_key_principals`
|
| 61 |
+
membership, never from a client-supplied workspace or role field.
|
| 62 |
+
|
| 63 |
## PostgreSQL role separation
|
| 64 |
|
| 65 |
Apply all security and social migrations with a controlled administrative
|