diff --git a/.env.example b/.env.example index 4880c02c70344195d15a5bb45bedf69ba615db2e..4af11eebfdb3959d0daaf7a97de374831bc213b3 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,58 @@ AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY=107374182400 AUTH_TRUST_PROXY_HEADERS=true # Required only when running the standalone stdio MCP transport with auth enabled. MCP_STDIO_API_KEY= + +# Social Automation Foundation. Apply all SQL files under app/social/migrations/ +# to Supabase/Postgres before enabling social writes in production. The existing +# DATABASE_URL remains a local-development fallback only. +SOCIAL_ENABLED=true +# Example: postgresql+asyncpg://postgres:password@db.example:5432/postgres +SOCIAL_DATABASE_URL= +SOCIAL_AUTO_MIGRATE=false +SOCIAL_WORKER_ENABLED=true +SOCIAL_SCHEDULER_INTERVAL_SECONDS=30 +SOCIAL_JOB_STALE_AFTER_SECONDS=900 +SOCIAL_PUBLISH_RETRY_LIMIT=5 +SOCIAL_OAUTH_REQUESTS_PER_HOUR=30 +SOCIAL_PUBLISH_REQUESTS_PER_MINUTE=30 +SOCIAL_SCHEDULE_REQUESTS_PER_MINUTE=60 +SOCIAL_ANALYTICS_REQUESTS_PER_MINUTE=120 +# Required for OAuth state/PKCE and encrypted local token fallback. Generate at +# least 32 random bytes, for example: openssl rand -base64 32 +SOCIAL_OAUTH_ENCRYPTION_KEY= +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= +SUPABASE_VAULT_ENABLED=false +SOCIAL_OAUTH_REDIRECT_BASE_URL= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# YouTube Data API v3 worker controls. Keep the client secret backend-only. +# Upload chunks must be a multiple of 262144 bytes (256 KiB). +YOUTUBE_UPLOAD_CHUNK_BYTES=8388608 +YOUTUBE_MAX_CONCURRENT_UPLOADS=2 +YOUTUBE_REQUEST_TIMEOUT_SECONDS=60 +YOUTUBE_PROCESSING_POLL_SECONDS=30 +META_CLIENT_ID= +META_CLIENT_SECRET= +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +# Exact backend callback registered under TikTok Login Kit. It must be: +# https:///v1/social/accounts/tiktok/callback +TIKTOK_REDIRECT_URI= +# Fail closed. Set true only after Content Posting API Direct Post approval and +# video.publish authorization are confirmed for this TikTok application. +TIKTOK_DIRECT_POST_ENABLED=false +# TikTok FILE_UPLOAD chunks: 5,000,000 through 64,000,000 bytes. +TIKTOK_UPLOAD_CHUNK_BYTES=10000000 +TIKTOK_REQUEST_TIMEOUT_SECONDS=60 +TIKTOK_PROCESSING_POLL_SECONDS=30 +# Test-only opt-in. Normal CI must keep this false; dedicated credentials and +# explicit publish consent are documented in social-tiktok-production-readiness.md. +RUN_TIKTOK_INTEGRATION_TESTS=false +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= +X_CLIENT_ID= +X_CLIENT_SECRET= +TELEGRAM_BOT_TOKEN= +WHATSAPP_CLIENT_ID= +WHATSAPP_CLIENT_SECRET= diff --git a/README.md b/README.md index 05738b14b64352b671314e54859a11b5e9bb0c65..68e9598e6478855e9173b540d04ec8bb0feb7d22 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,101 @@ Use one Uvicorn process in a CPU Space. FFmpeg and Whisper concurrency is manage For durable keys, audit records, and rate aggregates, attach Hugging Face persistent storage and set `DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db`. The default `./data/mediarouter.db` is appropriate locally but follows the Space filesystem lifecycle. The security layer uses SQLAlchemy so a future external database migration does not change authentication contracts; this release includes and supports the `aiosqlite` driver. +## Connect the Vercel frontend to Hugging Face + +The Next.js frontend is an authenticated backend-for-frontend (BFF): browser requests go to its same-origin `/api/backend/*` route, and only that server-side route attaches a backend API key. The browser must never receive an API key. All FFmpeg, Whisper, yt-dlp, uploads, and media processing remain in the Hugging Face Space. + +For the current hosted backend, the public origin is: + +```text +https://basyx-mediarouter.hf.space +``` + +First verify that the Space has started successfully. This endpoint is public and must return HTTP `200` with `"status": "healthy"` before the frontend can connect: + +```bash +curl -i https://basyx-mediarouter.hf.space/health +``` + +### Hugging Face Space secrets + +Create the bootstrap administrator locally, save its plaintext `API_KEY` in a password manager, and add **only** the three generated `AUTH_BOOTSTRAP_*` values to **Hugging Face Space → Settings → Secrets**. Enter each value in the value field only: no quotes, no `NAME=`, and no line breaks. Never commit these values. + +```text +AUTH_BOOTSTRAP_KEY_HASH=<64-character lowercase SHA-256 hash> +AUTH_BOOTSTRAP_KEY_PREFIX=mp_live_ +AUTH_BOOTSTRAP_ENVIRONMENT=live +``` + +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. + +Recommended backend deployment settings are: + +```env +BASE_URL=https://basyx-mediarouter.hf.space +DATABASE_URL=sqlite+aiosqlite:////data/mediarouter.db +AUTH_ROLE_SCOPES={} +MCP_STDIO_API_KEY= +``` + +`DATABASE_URL` uses `/data` so keys, audit logs, and rate-limit state survive only when Hugging Face persistent storage is attached. `BASE_URL` is the public Hugging Face origin, not the Vercel frontend URL. + +### Vercel environment variables + +Import this repository with `frontend` as the Vercel root directory. Configure the following values for **Production** and for **Preview** if preview deployments need to connect to the backend. Redeploy after changing an environment variable. + +```env +# Server-only backend connection and credential. Never use NEXT_PUBLIC_ for a key. +MEDIAROUTER_API_URL=https://basyx-mediarouter.hf.space +MEDIAROUTER_MCP_URL=https://basyx-mediarouter.hf.space +MEDIAROUTER_API_TOKEN=mp_live_ +MEDIAROUTER_API_TIMEOUT=120000 + +# Auth.js / OAuth (server only) +AUTH_SECRET= +NEXTAUTH_URL=https://.vercel.app +AUTH_GITHUB_ID= +AUTH_GITHUB_SECRET= +# Optional: configure both values to enable Google sign-in. +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= +AUTH_SESSION_MAX_AGE=28800 + +# Human-role mapping. Put your own OAuth email in the Admin list. +AUTH_USER_ROLES={} +AUTH_ADMIN_EMAILS= +AUTH_DEVELOPER_EMAILS= +AUTH_OPERATOR_EMAILS= +AUTH_VIEWER_EMAILS= +AUTH_DEFAULT_ROLE=Viewer + +# Public, non-secret display and capability settings. +NEXT_PUBLIC_API_URL=https://basyx-mediarouter.hf.space +NEXT_PUBLIC_MCP_URL=https://basyx-mediarouter.hf.space +NEXT_PUBLIC_APP_NAME=MediaRouter +NEXT_PUBLIC_ENABLE_MCP=true +NEXT_PUBLIC_ENABLE_MARKETPLACE=true +``` + +`MEDIAROUTER_API_TOKEN` is the server-only fallback for every human role. In a least-privilege production setup, create dedicated backend role keys after bootstrap and replace it with the appropriate keys instead: + +```env +MEDIAROUTER_ADMIN_API_TOKEN=mp_live_ +MEDIAROUTER_DEVELOPER_API_TOKEN=mp_live_ +MEDIAROUTER_OPERATOR_API_TOKEN=mp_live_ +MEDIAROUTER_VIEWER_API_TOKEN=mp_live_ +``` + +GitHub’s production callback URL is `https://.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. + +### Verify the complete connection + +1. Confirm `https://basyx-mediarouter.hf.space/health` returns `200`. +2. Redeploy Vercel after its environment variables are set. +3. Sign in using an OAuth account assigned to `AUTH_ADMIN_EMAILS`. +4. Visit the frontend `/api/backend/health` while signed in. It should proxy the healthy backend response. +5. If it returns `Backend authentication unavailable`, set `MEDIAROUTER_API_TOKEN` or the token matching the signed-in user’s role. If it returns `401`, the backend key is expired, disabled, revoked, or not the key represented by the Space bootstrap hash. + ## Authentication and authorization MediaRouter uses stateless opaque API keys. There are no passwords, login sessions, cookies, or JWTs. Except for the public endpoints below, every REST and MCP request must send: @@ -1084,6 +1179,53 @@ When the upstream binary property is `data`, use an expression body: For large files, prefer n8n's multipart binary option or raw binary body; JSON Base64 temporarily expands data by roughly 33%. +## Social Automation: YouTube Phase 2 + +MediaRouter now publishes YouTube videos through the shared `SocialService → SocialPublisher → YouTubeProvider` pipeline. REST, frontend, MCP, TypeScript/Python SDKs, and the n8n Social node use the same typed post, durable job, OAuth/token, validation, retry, and status-reconciliation implementation; none contains Google publishing logic or receives Google credentials. + +YouTube is implemented with the official YouTube Data API v3. It supports OAuth + PKCE, stable channel discovery, encrypted TokenService credentials, registered MediaRouter video outputs, typed YouTube metadata including the required audience declaration, resumable chunked upload, crash-safe external-video reconciliation, status polling, deletion, and supported video statistics. `GET /v1/social/providers` reports YouTube as implemented and reports the other requested providers as registered/unimplemented. Apply all social migrations in numeric order through `0004_youtube_media_assets.sql` before enabling production writes. + +Google setup, exact redirect URI, scope, output registration, metadata, scheduling, retries, quotas, analytics limits, and troubleshooting are documented in [docs/social-youtube.md](docs/social-youtube.md). The implementation report and live-test status are in [docs/social-youtube-production-readiness.md](docs/social-youtube-production-readiness.md). No staging credentials were supplied, so live Google verification is **NOT VERIFIED**. + +## Social Automation: TikTok Phase 4 + +TikTok Direct Post video publishing now uses the same SocialService, durable +SocialJob, TokenService, scheduler, retry, idempotency, and media-variant +pipeline. It calls only the official TikTok Content Posting API: creator info, +video initialization, sequential `FILE_UPLOAD` chunks, and publish-status +reconciliation. TikTok Direct Post is fail-closed behind +`TIKTOK_DIRECT_POST_ENABLED=false` and requires an explicit OAuth reconnect with +the approved `video.publish` scope. MediaRouter scheduling is supported; +TikTok-native scheduling and deletion are not advertised. See +[docs/social-tiktok-publishing.md](docs/social-tiktok-publishing.md) for +approval, media requirements, metadata, retries, idempotency, and integration +usage. Live TikTok verification requires a dedicated approved app and test +creator and is not part of normal CI. + +Phase 4C adds explicit `video.list` authorization and official Display API +video analytics, tenant/security hardening, credential redaction, optional live +integration coverage, and a classified readiness audit. See +[docs/social-tiktok-production-readiness.md](docs/social-tiktok-production-readiness.md). +Live TikTok, Postgres RLS, and Docker verification remain environment-dependent +and must not be inferred from configuration alone. + +### Social quick discovery + +```bash +curl -H "Authorization: Bearer $MEDIAROUTER_API_KEY" \ + https://your-space.hf.space/v1/social/providers +``` + +Register a completed MediaRouter output, then create a typed YouTube draft with a one-time idempotency key: + +```bash +curl -X POST https://your-space.hf.space/v1/social/posts \ + -H "Authorization: Bearer $MEDIAROUTER_API_KEY" \ + -H "Idempotency-Key: $(openssl rand -hex 16)" \ + -H "Content-Type: application/json" \ + -d '{"media_asset_id":"","publish_mode":"draft","targets":[{"social_account_id":"","youtube":{"title":"Example","description":"YouTube copy","privacy_status":"private","made_for_kids":false}}]}' +``` + ## Configuration | Variable | Default | Purpose | @@ -1118,6 +1260,32 @@ For large files, prefer n8n's multipart binary option or raw binary body; JSON B | `AUTH_DEFAULT_PROCESSING_BYTES_PER_DAY` | `107374182400` | Default daily uploaded processing bytes | | `AUTH_TRUST_PROXY_HEADERS` | `true` | Use first `X-Forwarded-For` address for audits behind HF/Vercel | | `MCP_STDIO_API_KEY` | empty | Existing API key required by authenticated standalone stdio MCP | +| `SOCIAL_ENABLED` | `true` | Enable the additive social domain; existing media APIs remain independent | +| `SOCIAL_DATABASE_URL` | `DATABASE_URL` | Async SQLAlchemy URL; use Supabase/Postgres in production | +| `SOCIAL_AUTO_MIGRATE` | `false` | Local/test metadata creation only; never use for production migration management | +| `SOCIAL_WORKER_ENABLED` | `true` | Run durable scheduler/publisher claim loop when schema is ready | +| `SOCIAL_SCHEDULER_INTERVAL_SECONDS` | `30` | Scheduler polling interval | +| `SOCIAL_JOB_STALE_AFTER_SECONDS` | `900` | Recover an interrupted active job after this worker-lease period | +| `SOCIAL_PUBLISH_RETRY_LIMIT` | `5` | Maximum provider publishing attempts | +| `SOCIAL_OAUTH_ENCRYPTION_KEY` | empty | Required secret for PKCE state and local encrypted token fallback | +| `SOCIAL_OAUTH_REDIRECT_BASE_URL` | empty | Public backend origin used to build exact provider callback URLs | +| `SUPABASE_VAULT_ENABLED` | `false` | Store provider tokens as Supabase Vault secret references | +| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | empty | Backend-only YouTube OAuth application credentials | +| `YOUTUBE_UPLOAD_CHUNK_BYTES` | `8388608` | Resumable upload chunk size; must be a multiple of 256 KiB | +| `YOUTUBE_MAX_CONCURRENT_UPLOADS` | `2` | Conservative per-process YouTube upload concurrency | +| `YOUTUBE_REQUEST_TIMEOUT_SECONDS` | `60` | Per-request YouTube Data API timeout | +| `YOUTUBE_PROCESSING_POLL_SECONDS` | `30` | Delay between server-side processing reconciliation polls | +| `META_CLIENT_ID`, `META_CLIENT_SECRET` | empty | Backend-only Facebook/Instagram OAuth application credentials | +| `TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET` | empty | Backend-only TikTok Login Kit application credentials | +| `TIKTOK_REDIRECT_URI` | empty | Exact backend-owned TikTok callback registered in Login Kit (`https:///v1/social/accounts/tiktok/callback`) | +| `TIKTOK_DIRECT_POST_ENABLED` | `false` | Fail-closed Direct Post gate; enable only after TikTok Content Posting approval | +| `TIKTOK_UPLOAD_CHUNK_BYTES` | `10000000` | Sequential FILE_UPLOAD chunk target (5–64 MB; final chunk may be up to 128 MB) | +| `TIKTOK_REQUEST_TIMEOUT_SECONDS` | `60` | TikTok OAuth, Content Posting, and upload request timeout | +| `TIKTOK_PROCESSING_POLL_SECONDS` | `30` | Delay between official publish-status reconciliation polls | +| `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET` | empty | Backend-only LinkedIn OAuth credentials | +| `X_CLIENT_ID`, `X_CLIENT_SECRET` | empty | Backend-only X OAuth credentials | +| `TELEGRAM_BOT_TOKEN` | empty | Backend-only Telegram bot credential foundation | +| `WHATSAPP_CLIENT_ID`, `WHATSAPP_CLIENT_SECRET` | empty | Backend-only WhatsApp Business credentials | ## Logging and safety diff --git a/app/api/social.py b/app/api/social.py new file mode 100644 index 0000000000000000000000000000000000000000..6ebf62724207abca546b064b348b312902aacb74 --- /dev/null +++ b/app/api/social.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Header, Query, Request, Response, status + +from app.social.schemas.accounts import ( + SocialAccountConnectRequest, + SocialAccountView, + SocialConnectResponse, + SocialPublishOptionsView, + SocialProviderView, +) +from app.social.schemas.assets import ( + SocialMediaAssetRegister, + SocialMediaAssetView, +) +from app.social.schemas.jobs import SocialJobView +from app.social.schemas.posts import SocialPostCreate, SocialPostView +from app.social.schemas.scheduling import SocialScheduleCreate, SocialScheduleView + +router = APIRouter(prefix="/v1/social", tags=["social automation"]) + + +def _social(request: Request): + service = request.app.state.container.social + service.ensure_ready() + return service + + +def _identity(request: Request) -> tuple[str, str]: + context = request.state.auth + # Phase 1 isolation: the authenticated API-key identity is the tenant + # boundary until the backend gains a native workspace membership model. + return context.api_key_id, context.api_key_id + + +async def _audit( + request: Request, + event_type: str, + *, + provider: str | None = None, + account_id: str | None = None, + post_id: str | None = None, + job_id: str | None = None, +) -> None: + workspace_id, user_id = _identity(request) + await request.app.state.container.social.audit.record( + workspace_id=workspace_id, + event_type=event_type, + api_key_id=user_id, + request_id=request.state.request_id, + provider=provider, + social_account_id=account_id, + social_post_id=post_id, + social_job_id=job_id, + ) + + +@router.get("/providers", response_model=list[SocialProviderView]) +async def list_providers(request: Request) -> list[SocialProviderView]: + return request.app.state.container.social.accounts.list_providers() + + +@router.get( + "/providers/{provider}/capabilities", response_model=SocialProviderView +) +async def provider_capabilities(request: Request, provider: str) -> SocialProviderView: + return request.app.state.container.social.accounts.get_provider(provider) + + +@router.get("/assets", response_model=list[SocialMediaAssetView]) +async def list_social_media_assets( + request: Request, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), +) -> list[SocialMediaAssetView]: + workspace_id, _ = _identity(request) + return await _social(request).media_assets.list(workspace_id, offset=offset, limit=limit) + + +@router.post("/assets", response_model=SocialMediaAssetView, status_code=status.HTTP_201_CREATED) +async def register_social_media_asset( + request: Request, payload: SocialMediaAssetRegister +) -> SocialMediaAssetView: + workspace_id, _ = _identity(request) + return await _social(request).media_assets.register(workspace_id, payload) + + +@router.get("/accounts", response_model=list[SocialAccountView]) +async def list_accounts( + request: Request, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), +) -> list[SocialAccountView]: + workspace_id, _ = _identity(request) + return await _social(request).accounts.list( + workspace_id, offset=offset, limit=limit + ) + + +@router.get("/accounts/{account_id}", response_model=SocialAccountView) +async def get_account(request: Request, account_id: str) -> SocialAccountView: + workspace_id, _ = _identity(request) + return await _social(request).accounts.get(workspace_id, account_id) + + +@router.get( + "/accounts/{account_id}/publish-options", + response_model=SocialPublishOptionsView, +) +async def get_account_publish_options( + request: Request, account_id: str +) -> SocialPublishOptionsView: + workspace_id, _ = _identity(request) + return await _social(request).publishing.publish_options( + workspace_id, account_id + ) + + +@router.post( + "/accounts/{provider}/connect", response_model=SocialConnectResponse +) +async def connect_account( + request: Request, provider: str, payload: SocialAccountConnectRequest +) -> SocialConnectResponse: + workspace_id, user_id = _identity(request) + result = await _social(request).oauth.connect( + provider=provider, + workspace_id=workspace_id, + user_id=user_id, + payload=payload, + ) + await _audit(request, "SOCIAL_ACCOUNT_CONNECTION_STARTED", provider=provider) + return result + + +@router.get( + "/accounts/{provider}/callback", + response_model=SocialAccountView, + include_in_schema=True, +) +async def oauth_callback( + request: Request, + provider: str, + state: Annotated[ + str, Query(min_length=32, max_length=255, pattern=r"^[A-Za-z0-9_-]+$") + ], + code: Annotated[str | None, Query(min_length=1, max_length=4096)] = None, + error: Annotated[str | None, Query(max_length=128)] = None, +) -> SocialAccountView: + # This provider-facing route is authenticated by a short-lived, single-use + # state record. Tenant/user identifiers are never accepted from the query. + social = request.app.state.container.social + social.ensure_ready() + if error or not code: + await social.oauth.callback_denied(provider=provider, state=state) + raise AssertionError("OAuth callback denial should raise a social error") + return await social.oauth.callback(provider=provider, state=state, code=code) + + +@router.post("/accounts/{account_id}/refresh", response_model=SocialAccountView) +async def refresh_account(request: Request, account_id: str) -> SocialAccountView: + workspace_id, _ = _identity(request) + result = await _social(request).oauth.refresh( + workspace_id=workspace_id, account_id=account_id + ) + await _audit(request, "SOCIAL_ACCOUNT_REAUTHORIZED", account_id=account_id) + return result + + +@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT) +async def disconnect_account(request: Request, account_id: str) -> Response: + workspace_id, _ = _identity(request) + account = await _social(request).accounts.get(workspace_id, account_id) + await request.app.state.container.social.accounts.disconnect(workspace_id, account_id) + await _audit( + request, + "SOCIAL_ACCOUNT_DISCONNECTED", + provider=account.provider.value, + account_id=account_id, + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/posts", response_model=SocialPostView, status_code=status.HTTP_201_CREATED +) +async def create_post( + request: Request, + payload: SocialPostCreate, + idempotency_key: str | None = Header( + default=None, alias="Idempotency-Key", min_length=8, max_length=255 + ), +) -> SocialPostView: + workspace_id, user_id = _identity(request) + result = await _social(request).publishing.create( + workspace_id=workspace_id, + user_id=user_id, + payload=payload, + idempotency_key=idempotency_key, + ) + await _audit(request, "SOCIAL_POST_CREATED", post_id=result.id) + return result + + +@router.get("/posts", response_model=list[SocialPostView]) +async def list_posts( + request: Request, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), +) -> list[SocialPostView]: + workspace_id, _ = _identity(request) + return await _social(request).publishing.list( + workspace_id, offset=offset, limit=limit + ) + + +@router.get("/posts/{post_id}", response_model=SocialPostView) +async def get_post(request: Request, post_id: str) -> SocialPostView: + workspace_id, _ = _identity(request) + return await _social(request).publishing.get(workspace_id, post_id) + + +@router.delete("/posts/{post_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_post(request: Request, post_id: str) -> Response: + workspace_id, _ = _identity(request) + await _social(request).publishing.delete(workspace_id, post_id) + await _audit(request, "SOCIAL_POST_DELETED", post_id=post_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post( + "/posts/{post_id}/publish", + response_model=list[SocialJobView], + status_code=status.HTTP_202_ACCEPTED, +) +async def publish_post( + request: Request, + post_id: str, + idempotency_key: str = Header(alias="Idempotency-Key", min_length=8, max_length=255), +) -> list[SocialJobView]: + workspace_id, _ = _identity(request) + jobs = await _social(request).publishing.queue( + workspace_id, post_id, idempotency_key=idempotency_key + ) + return jobs + + +@router.post("/posts/{post_id}/schedule", response_model=SocialScheduleView) +async def schedule_post( + request: Request, post_id: str, payload: SocialScheduleCreate +) -> SocialScheduleView: + workspace_id, _ = _identity(request) + await _social(request).publishing.validate_post_targets(workspace_id, post_id) + schedule = await _social(request).scheduling.schedule( + workspace_id, post_id, payload + ) + await _audit(request, "SOCIAL_SCHEDULE_CREATED", post_id=post_id) + return schedule + + +@router.post("/posts/{post_id}/cancel", response_model=SocialPostView) +async def cancel_post(request: Request, post_id: str) -> SocialPostView: + workspace_id, _ = _identity(request) + result = await _social(request).publishing.cancel(workspace_id, post_id) + await _audit(request, "SOCIAL_POST_CANCELLED", post_id=post_id) + return result + + +@router.get("/jobs", response_model=list[SocialJobView]) +async def list_jobs( + request: Request, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=500), +) -> list[SocialJobView]: + workspace_id, _ = _identity(request) + return await _social(request).jobs.list( + workspace_id, offset=offset, limit=limit + ) + + +@router.get("/jobs/{job_id}", response_model=SocialJobView) +async def get_job(request: Request, job_id: str) -> SocialJobView: + workspace_id, _ = _identity(request) + return await _social(request).jobs.get(workspace_id, job_id) + + +@router.get("/accounts/{account_id}/analytics") +async def account_analytics(request: Request, account_id: str) -> dict[str, object]: + workspace_id, _ = _identity(request) + return await _social(request).analytics.account(workspace_id, account_id) + + +@router.get("/posts/{post_id}/analytics") +async def post_analytics(request: Request, post_id: str) -> dict[str, object]: + workspace_id, _ = _identity(request) + return await _social(request).analytics.post(workspace_id, post_id) diff --git a/app/container.py b/app/container.py index b03e065a42b73f30225a44c2cb1f62604d4b3a84..aa96332f3056cdb1d23c4b863e8918dbcb9e8d99 100644 --- a/app/container.py +++ b/app/container.py @@ -3,6 +3,10 @@ from __future__ import annotations from dataclasses import dataclass from app.core.config import Settings +from app.security.audit import AuditService +from app.security.database import SecurityDatabase +from app.security.rate_limit import APIKeyRateLimiter +from app.security.service import APIKeyService from app.services.cleanup import CleanupService from app.services.downloader import Downloader from app.services.ffmpeg_service import FFmpegService @@ -12,10 +16,25 @@ from app.services.media_service import MediaProcessor from app.services.validator import MediaValidator from app.services.whisper_service import WhisperService from app.services.ytdlp_service import YTDLPService -from app.security.audit import AuditService -from app.security.database import SecurityDatabase -from app.security.rate_limit import APIKeyRateLimiter -from app.security.service import APIKeyService +from app.social.database import SocialDatabase +from app.social.oauth.encryption import TokenCipher +from app.social.oauth.state import OAuthStateService +from app.social.providers.registry import build_provider_registry +from app.social.repositories.accounts import AccountRepository +from app.social.repositories.assets import SocialMediaAssetRepository +from app.social.repositories.jobs import JobRepository +from app.social.repositories.posts import PostRepository +from app.social.repositories.tokens import TokenRepository +from app.social.services.account_service import AccountService +from app.social.services.analytics_service import AnalyticsService +from app.social.services.audit_service import SocialAuditService +from app.social.services.job_service import JobService +from app.social.services.media_asset_service import SocialMediaAssetService +from app.social.services.oauth_service import OAuthService +from app.social.services.publishing_service import PublishingService +from app.social.services.scheduling_service import SchedulingService +from app.social.services.social_service import SocialService +from app.social.services.token_service import TokenService from app.templates.executor import OperationExecutor, TemplateExecutor from app.templates.loader import TemplateLoader from app.templates.registry import TemplateRegistry @@ -40,6 +59,7 @@ class Container: api_keys: APIKeyService rate_limiter: APIKeyRateLimiter audit: AuditService + social: SocialService def build_container(settings: Settings) -> Container: @@ -47,6 +67,8 @@ def build_container(settings: Settings) -> Container: api_keys = APIKeyService(security_database, settings) rate_limiter = APIKeyRateLimiter(security_database) audit = AuditService(security_database) + # Social publishing validates the same file-backed outputs as the normal + # media pipeline, so build those shared services before wiring Social. cleanup = CleanupService(settings) validator = MediaValidator(settings) downloader = Downloader(settings, validator) @@ -54,6 +76,48 @@ def build_container(settings: Settings) -> Container: ffmpeg = FFmpegService(settings) ffprobe = FFprobeService(settings) whisper = WhisperService(settings) + social_database = SocialDatabase(settings) + providers = build_provider_registry(settings) + account_repository = AccountRepository(social_database) + post_repository = PostRepository(social_database) + token_cipher = TokenCipher( + settings.social_oauth_encryption_key.get_secret_value() + if settings.social_oauth_encryption_key + else None + ) + job_repository = JobRepository(social_database, token_cipher) + token_service = TokenService( + TokenRepository(social_database), + settings, + token_cipher, + ) + account_service = AccountService(account_repository, token_service, providers) + social_audit = SocialAuditService(social_database) + oauth_service = OAuthService( + settings, + providers, + OAuthStateService(social_database, token_service.cipher), + account_service, + social_audit, + ) + social_media_assets = SocialMediaAssetService( + SocialMediaAssetRepository(social_database), cleanup, ffprobe, validator + ) + social = SocialService( + settings=settings, + database=social_database, + accounts=account_service, + oauth=oauth_service, + publishing=PublishingService( + settings, post_repository, job_repository, account_repository, providers, + social_media_assets, oauth_service, + ), + scheduling=SchedulingService(post_repository, social_media_assets), + jobs=JobService(job_repository), + media_assets=social_media_assets, + analytics=AnalyticsService(social_database, account_repository, providers, oauth_service), + audit=social_audit, + ) resolver = InputResolver(settings, cleanup, downloader, ytdlp, validator) processor = MediaProcessor( settings, resolver, cleanup, validator, ffmpeg, ffprobe, ytdlp, whisper @@ -81,4 +145,5 @@ def build_container(settings: Settings) -> Container: api_keys=api_keys, rate_limiter=rate_limiter, audit=audit, + social=social, ) diff --git a/app/core/config.py b/app/core/config.py index bc2b06f8ca571a0ddc5700dfb43570e01c14ccbc..af26b3a5a532d04380733009077253de3cdcacc1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,7 +1,9 @@ from __future__ import annotations +import re from functools import lru_cache from pathlib import Path +from urllib.parse import urlparse from pydantic import Field, SecretStr, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -52,6 +54,59 @@ class Settings(BaseSettings): ) auth_trust_proxy_headers: bool = True mcp_stdio_api_key: SecretStr | None = None + # Social Automation foundation. The existing database remains the default + # local store; production Supabase/Postgres deployments should set a + # dedicated async SQLAlchemy URL and apply the SQL migration out-of-band. + social_enabled: bool = True + social_database_url: str = "" + social_auto_migrate: bool = False + social_worker_enabled: bool = True + social_scheduler_interval_seconds: int = Field(default=30, ge=5, le=3600) + social_job_stale_after_seconds: int = Field(default=900, ge=60, le=86_400) + social_publish_retry_limit: int = Field(default=5, ge=0, le=20) + social_oauth_requests_per_hour: int = Field(default=30, ge=1, le=100_000) + social_publish_requests_per_minute: int = Field(default=30, ge=1, le=100_000) + social_schedule_requests_per_minute: int = Field(default=60, ge=1, le=100_000) + social_analytics_requests_per_minute: int = Field(default=120, ge=1, le=100_000) + social_oauth_encryption_key: SecretStr | None = None + supabase_url: str = "" + supabase_service_role_key: SecretStr | None = None + supabase_vault_enabled: bool = False + google_client_id: str = "" + google_client_secret: SecretStr | None = None + youtube_upload_chunk_bytes: int = Field(default=8 * 1024 * 1024, ge=256 * 1024) + youtube_max_concurrent_uploads: int = Field(default=2, ge=1, le=32) + youtube_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) + youtube_processing_poll_seconds: int = Field(default=30, ge=5, le=3600) + meta_client_id: str = "" + meta_client_secret: SecretStr | None = None + # META_APP_* is the public configuration contract. META_CLIENT_* remains + # supported for deployments created during the social foundation phase. + meta_app_id: str = "" + meta_app_secret: SecretStr | None = None + meta_graph_api_version: str = "v25.0" + tiktok_client_key: str = "" + tiktok_client_secret: SecretStr | None = None + # Exact, backend-owned OAuth callback registered in TikTok Login Kit. + # This is intentionally separate from the secret and is never a frontend + # configuration value. + tiktok_redirect_uri: str = "" + # Direct Post requires TikTok Content Posting approval and an audited app. + # Keep it fail-closed until an operator has confirmed that access. + tiktok_direct_post_enabled: bool = False + tiktok_upload_chunk_bytes: int = Field( + default=10_000_000, ge=5_000_000, le=64_000_000 + ) + tiktok_request_timeout_seconds: float = Field(default=60.0, gt=0, le=600) + tiktok_processing_poll_seconds: int = Field(default=30, ge=5, le=3600) + linkedin_client_id: str = "" + linkedin_client_secret: SecretStr | None = None + x_client_id: str = "" + x_client_secret: SecretStr | None = None + telegram_bot_token: SecretStr | None = None + whatsapp_client_id: str = "" + whatsapp_client_secret: SecretStr | None = None + social_oauth_redirect_base_url: str = "" @field_validator("whisper_model") @classmethod @@ -74,14 +129,28 @@ class Settings(BaseSettings): self.temp_dir.mkdir(parents=True, exist_ok=True) self.output_dir.mkdir(parents=True, exist_ok=True) sqlite_prefixes = ("sqlite+aiosqlite:///", "sqlite:///") - for prefix in sqlite_prefixes: - if self.database_url.startswith(prefix): - database_path = self.database_url.removeprefix(prefix) - if database_path and database_path != ":memory:": - Path(database_path).expanduser().resolve().parent.mkdir( - parents=True, exist_ok=True - ) - break + for url in {self.database_url, self.resolved_social_database_url}: + for prefix in sqlite_prefixes: + if url.startswith(prefix): + database_path = url.removeprefix(prefix) + if database_path and database_path != ":memory:": + Path(database_path).expanduser().resolve().parent.mkdir( + parents=True, exist_ok=True + ) + break + + @property + def resolved_social_database_url(self) -> str: + """Use an explicit social database when configured, otherwise local DB.""" + return self.social_database_url.strip() or self.database_url + + @property + def resolved_meta_app_id(self) -> str: + return self.meta_app_id.strip() or self.meta_client_id.strip() + + @property + def resolved_meta_app_secret(self) -> SecretStr | None: + return self.meta_app_secret or self.meta_client_secret @field_validator("auth_bootstrap_environment") @classmethod @@ -91,6 +160,46 @@ class Settings(BaseSettings): raise ValueError("AUTH_BOOTSTRAP_ENVIRONMENT must be live or test") return normalized + @field_validator("youtube_upload_chunk_bytes") + @classmethod + def validate_youtube_chunk_size(cls, value: int) -> int: + # Google resumable uploads require every non-final chunk to be aligned + # to 256 KiB. Keeping the constraint at configuration time avoids a + # late failure after an upload session was already created. + if value % (256 * 1024): + raise ValueError("YOUTUBE_UPLOAD_CHUNK_BYTES must be a multiple of 262144") + return value + + @field_validator("meta_graph_api_version") + @classmethod + def validate_meta_graph_api_version(cls, value: str) -> str: + normalized = value.strip() + if not re.fullmatch(r"v[0-9]+\.[0-9]+", normalized): + raise ValueError("META_GRAPH_API_VERSION must use the form vNN.N") + return normalized + + @field_validator("tiktok_redirect_uri") + @classmethod + def validate_tiktok_redirect_uri(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + return "" + parsed = urlparse(normalized) + local_hosts = {"localhost", "127.0.0.1", "::1"} + if ( + not parsed.netloc + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or parsed.path != "/v1/social/accounts/tiktok/callback" + or (parsed.scheme != "https" and parsed.hostname not in local_hosts) + ): + raise ValueError( + "TIKTOK_REDIRECT_URI must be the HTTPS MediaRouter TikTok callback URI" + ) + return normalized + @lru_cache def get_settings() -> Settings: diff --git a/app/core/logger.py b/app/core/logger.py index 96f4005c2ce6d519c614537efdd3b9d5ffad3f58..d987f5b070b4c4f3c0cbcf803af1f5b17aa6f494 100644 --- a/app/core/logger.py +++ b/app/core/logger.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextvars import json import logging +import re import sys from datetime import datetime, timezone from typing import Any, TextIO @@ -16,29 +17,75 @@ class JsonFormatter(logging.Formatter): """One-line structured JSON logs suitable for container log collectors.""" _standard = set(logging.makeLogRecord({}).__dict__) | {"message", "asctime"} + _sensitive_keys = frozenset( + { + "access_token", + "refresh_token", + "id_token", + "authorization", + "client_secret", + "secret", + "password", + "api_key", + "credential", + "cookie", + } + ) + _bearer = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+") + _assigned_secret = re.compile( + r"(?i)(access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|" + r"authorization|api[_-]?key|password|secret|credential)" + r"([\"']?\s*[:=]\s*[\"']?)([^\"'\s,&}]+)" + ) def format(self, record: logging.LogRecord) -> str: payload: dict[str, Any] = { "timestamp": datetime.now(timezone.utc).isoformat(), "level": record.levelname, "logger": record.name, - "message": record.getMessage(), + "message": self._redact_text(record.getMessage()), "request_id": getattr(record, "request_id", request_id_context.get()), } for key, value in record.__dict__.items(): if key not in self._standard and not key.startswith("_"): - payload[key] = self._json_safe(value) + payload[key] = self._json_safe(value, key=key) if record.exc_info: - payload["exception"] = self.formatException(record.exc_info) + payload["exception"] = self._redact_text( + self.formatException(record.exc_info) + ) return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - @staticmethod - def _json_safe(value: Any) -> Any: + @classmethod + def _json_safe(cls, value: Any, *, key: str | None = None) -> Any: + if key is not None and cls._is_sensitive_key(key): + return "[REDACTED]" + if isinstance(value, dict): + return { + str(item_key): cls._json_safe(item, key=str(item_key)) + for item_key, item in value.items() + } + if isinstance(value, (list, tuple, set)): + return [cls._json_safe(item) for item in value] + if isinstance(value, str): + return cls._redact_text(value) try: json.dumps(value) return value except (TypeError, ValueError): - return str(value) + return cls._redact_text(str(value)) + + @classmethod + def _is_sensitive_key(cls, key: str) -> bool: + normalized = key.strip().lower().replace("-", "_") + return any(part in normalized for part in cls._sensitive_keys) + + @classmethod + def _redact_text(cls, value: str) -> str: + redacted = cls._bearer.sub("Bearer [REDACTED]", value) + return cls._assigned_secret.sub( + lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]", + redacted, + ) def configure_logging(stream: TextIO | None = None) -> None: diff --git a/app/mcp/server.py b/app/mcp/server.py index 002c216b5950327fb7f346ba32b50c0000c05e6b..d5a598ec410044c45aac3b96c840ce243cf5f08a 100644 --- a/app/mcp/server.py +++ b/app/mcp/server.py @@ -19,6 +19,7 @@ from app.mcp.resources import register_resources from app.mcp.tools.audio import register_audio_tools from app.mcp.tools.image import register_image_tools from app.mcp.tools.probe import register_probe_tools +from app.mcp.tools.social import register_social_tools from app.mcp.tools.system import register_system_tools from app.mcp.tools.templates import register_template_tools from app.mcp.tools.video import register_video_tools @@ -56,6 +57,7 @@ def create_mcp_server(container: Container) -> FastMCP[Any]: register_probe_tools(server, registry) register_system_tools(server, registry) register_template_tools(server, registry) + register_social_tools(server, registry) register_resources(server, registry) register_prompts(server) return server @@ -70,6 +72,7 @@ async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") - server = create_mcp_server(container) await container.security_database.initialize() await container.api_keys.ensure_bootstrap_admin() + await container.social.initialize() await worker.start() try: if transport == "stdio": @@ -116,6 +119,7 @@ async def run_server(transport: Literal["stdio", "streamable-http"] = "stdio") - await uvicorn.Server(config).serve() finally: await worker.stop() + await container.social.close() await container.security_database.close() diff --git a/app/mcp/tools/social.py b/app/mcp/tools/social.py new file mode 100644 index 0000000000000000000000000000000000000000..4af94b91c1acd1e2cb47b15d188222cc2e2c5932 --- /dev/null +++ b/app/mcp/tools/social.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from typing import Any, Literal + +from mcp.server.fastmcp import FastMCP + +from app.mcp.registry import MCPRegistry +from app.security.context import auth_context +from app.social.schemas.posts import SocialPostCreate +from app.social.schemas.assets import SocialMediaAssetRegister +from app.social.schemas.scheduling import SocialScheduleCreate + + +def register_social_tools(server: FastMCP[Any], registry: MCPRegistry) -> None: + """Register thin MCP transports over the shared SocialService facade.""" + + def identity() -> str: + context = auth_context.get() + if context is None: # _execute normally rejects this first. + return "" + return context.api_key_id + + @server.tool( + name="social.list_providers", + description="List registered social providers and their explicit capabilities.", + ) + async def social_list_providers() -> dict[str, Any]: + async def action() -> dict[str, Any]: + items = registry.container.social.accounts.list_providers() + return {"providers": [item.model_dump(mode="json") for item in items]} + + return await registry.run_metadata_tool( + "social.list_providers", action, required_scope="social:accounts:read" + ) + + @server.tool( + name="social.get_capabilities", + description="Get capabilities and implementation status for one provider.", + ) + async def social_get_capabilities(provider: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + item = registry.container.social.accounts.get_provider(provider) + return {"provider": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.get_capabilities", action, required_scope="social:accounts:read" + ) + + @server.tool( + name="social.list_media_assets", + description="List workspace-owned MediaRouter outputs registered for social publishing.", + ) + async def social_list_media_assets(offset: int = 0, limit: int = 100) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + items = await social.media_assets.list(identity(), offset=offset, limit=limit) + return {"assets": [item.model_dump(mode="json") for item in items]} + + return await registry.run_metadata_tool( + "social.list_media_assets", action, required_scope="assets:read" + ) + + @server.tool( + name="social.register_media_asset", + description="Register an existing MediaRouter output UUID and filename for tenant-scoped social publishing.", + ) + async def social_register_media_asset(request_id: str, filename: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + item = await social.media_assets.register( + identity(), + SocialMediaAssetRegister(request_id=request_id, filename=filename), + ) + return {"asset": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.register_media_asset", action, required_scope="assets:write" + ) + + @server.tool(name="social.list_accounts", description="List connected social accounts.") + async def social_list_accounts(offset: int = 0, limit: int = 100) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + items = await social.accounts.list(identity(), offset=offset, limit=limit) + return {"accounts": [item.model_dump(mode="json") for item in items]} + + return await registry.run_metadata_tool( + "social.list_accounts", action, required_scope="social:accounts:read" + ) + + @server.tool(name="social.get_account", description="Get a tenant-owned social account.") + async def social_get_account(account_id: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + item = await social.accounts.get(identity(), account_id) + return {"account": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.get_account", action, required_scope="social:accounts:read" + ) + + @server.tool(name="social.create_post", description="Create a typed multi-target social post.") + async def social_create_post( + payload: dict[str, Any], idempotency_key: str | None = None + ) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + user_id = identity() + item = await social.publishing.create( + workspace_id=user_id, + user_id=user_id, + payload=SocialPostCreate.model_validate(payload), + idempotency_key=idempotency_key, + ) + await social.audit.record( + workspace_id=user_id, + api_key_id=user_id, + event_type="SOCIAL_POST_CREATED", + social_post_id=item.id, + ) + return {"post": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.create_post", action, required_scope="social:posts:write" + ) + + @server.tool(name="social.publish_post", description="Queue an idempotent social post publication.") + async def social_publish_post(post_id: str, idempotency_key: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + jobs = await social.publishing.queue( + identity(), post_id, idempotency_key=idempotency_key + ) + return {"jobs": [job.model_dump(mode="json") for job in jobs]} + + return await registry.run_metadata_tool( + "social.publish_post", action, required_scope="social:posts:publish" + ) + + @server.tool(name="social.schedule_post", description="Schedule a social post using an aware timestamp and IANA timezone.") + async def social_schedule_post( + post_id: str, scheduled_at: str, timezone: str + ) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + schedule = await social.scheduling.schedule( + identity(), + post_id, + SocialScheduleCreate( + scheduled_at=scheduled_at, # type: ignore[arg-type] + timezone=timezone, + ), + ) + return {"schedule": schedule.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.schedule_post", action, required_scope="social:schedules:write" + ) + + @server.tool(name="social.cancel_post", description="Cancel a scheduled or active social post.") + async def social_cancel_post(post_id: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + item = await social.publishing.cancel(identity(), post_id) + return {"post": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.cancel_post", action, required_scope="social:posts:write" + ) + + @server.tool(name="social.get_post", description="Get a multi-target social post and target states.") + async def social_get_post(post_id: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + item = await social.publishing.get(identity(), post_id) + return {"post": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.get_post", action, required_scope="social:posts:read" + ) + + @server.tool(name="social.get_job", description="Get a durable social publishing job.") + async def social_get_job(job_id: str) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + item = await social.jobs.get(identity(), job_id) + return {"job": item.model_dump(mode="json")} + + return await registry.run_metadata_tool( + "social.get_job", action, required_scope="social:posts:read" + ) + + @server.tool(name="social.get_analytics", description="Get normalized metrics for an account or post.") + async def social_get_analytics( + resource: Literal["account", "post"], resource_id: str + ) -> dict[str, Any]: + async def action() -> dict[str, Any]: + social = registry.container.social + social.ensure_ready() + if resource == "account": + return await social.analytics.account(identity(), resource_id) + return await social.analytics.post(identity(), resource_id) + + return await registry.run_metadata_tool( + "social.get_analytics", action, required_scope="social:analytics:read" + ) diff --git a/app/security/middleware.py b/app/security/middleware.py index 6b88579d887697bcfd563ac742db37dc7f6beb86..a1d450267a47fb8b5a3b49c6f73b00f400234888 100644 --- a/app/security/middleware.py +++ b/app/security/middleware.py @@ -68,6 +68,7 @@ class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware): uploaded_bytes=bytes_uploaded, ) self.api_keys.authorize(context, required_scope) + await self._apply_social_rate_limit(request, context) await self.api_keys.mark_used(context) http_auth_token = http_auth_applied.set(True) response = await call_next(request) @@ -120,6 +121,41 @@ class APIKeyAuthenticationMiddleware(BaseHTTPMiddleware): bytes_downloaded, ) + async def _apply_social_rate_limit( + self, request: Request, context: AuthContext + ) -> None: + path = request.url.path + if not path.startswith("/v1/social"): + return + if "/analytics" in path: + await self.rate_limiter.acquire_category( + context, + "social_analytics", + limit=self.settings.social_analytics_requests_per_minute, + window_seconds=60, + ) + elif path.endswith("/connect") or path.endswith("/callback") or path.endswith("/refresh"): + await self.rate_limiter.acquire_category( + context, + "social_oauth", + limit=self.settings.social_oauth_requests_per_hour, + window_seconds=3600, + ) + elif path.endswith("/publish"): + await self.rate_limiter.acquire_category( + context, + "social_publish", + limit=self.settings.social_publish_requests_per_minute, + window_seconds=60, + ) + elif path.endswith("/schedule"): + await self.rate_limiter.acquire_category( + context, + "social_schedule", + limit=self.settings.social_schedule_requests_per_minute, + window_seconds=60, + ) + @staticmethod def _bearer_token(header: str | None) -> str: if not header: diff --git a/app/security/policy.py b/app/security/policy.py index efc2c03c37246188fff47d25ab5291ab4d8b1b8f..c48e1bb628e4c426e5f2e4ed10f7cae808686b7f 100644 --- a/app/security/policy.py +++ b/app/security/policy.py @@ -14,13 +14,26 @@ class ScopePolicy: @staticmethod def is_public(request: Request) -> bool: - return request.method == "GET" and request.url.path in PUBLIC_GET_PATHS + path = request.url.path + callback_segments = tuple(segment for segment in path.split("/") if segment) + return request.method == "GET" and ( + path in PUBLIC_GET_PATHS + # OAuth callbacks deliberately rely on a single-use, short-lived + # state record instead of an API key that a provider cannot send. + # Keep this exemption exact: no nested route may accidentally + # inherit callback's public status. + or callback_segments[:3] == ("v1", "social", "accounts") + and len(callback_segments) == 5 + and callback_segments[-1] == "callback" + ) async def required_scope(self, request: Request) -> str | None: path = request.url.path method = request.method if path == "/v1/auth/context": return None + if path.startswith("/v1/social"): + return self._social_scope(path, method) if path.startswith("/mcp"): return await self._mcp_scope(request) if path.startswith("/v1/api-keys") or path.startswith("/v1/audit-logs"): @@ -53,6 +66,28 @@ class ScopePolicy: return "system:read" return "admin" + @staticmethod + def _social_scope(path: str, method: str) -> str: + if "/assets" in path: + return "assets:read" if method == "GET" else "assets:write" + if "/analytics" in path: + return "social:analytics:read" + if "/jobs" in path: + return "social:posts:read" + if "/accounts" in path: + return "social:accounts:read" if method == "GET" else "social:accounts:write" + if "/posts" in path: + if method == "GET": + return "social:posts:read" + if path.endswith("/publish"): + return "social:posts:publish" + if path.endswith("/schedule"): + return "social:schedules:write" + if path.endswith("/cancel"): + return "social:posts:write" + return "social:posts:write" + return "social:accounts:read" + @staticmethod async def _mcp_scope(request: Request) -> str: if request.method != "POST": @@ -76,6 +111,7 @@ class ScopePolicy: "operations:execute", "jobs:create", "mcp:execute", + "social:posts:publish", } @staticmethod diff --git a/app/security/rate_limit.py b/app/security/rate_limit.py index 42729db05663ac93dd2990f250dacfb7e89f6da4..4255d17cc84845061800b30e33b597c4169b0949 100644 --- a/app/security/rate_limit.py +++ b/app/security/rate_limit.py @@ -36,6 +36,7 @@ class APIKeyRateLimiter: self._uploads: dict[str, deque[float]] = defaultdict(deque) self._concurrent: dict[str, int] = defaultdict(int) self._daily_bytes: dict[tuple[str, str], int] = defaultdict(int) + self._categories: dict[tuple[str, str], deque[float]] = defaultdict(deque) async def acquire( self, @@ -96,6 +97,30 @@ class APIKeyRateLimiter: async with self._lock: self._concurrent[api_key_id] = max(0, self._concurrent[api_key_id] - 1) + async def acquire_category( + self, + context: AuthContext, + category: str, + *, + limit: int, + window_seconds: int, + ) -> None: + """Reserve an independent social-operation bucket for a key. + + Generic API limits still apply in middleware. These smaller buckets + prevent OAuth, publishing, scheduling, and analytics traffic from + starving each other when the social subsystem is enabled. + """ + now = time.time() + key = (context.api_key_id, category) + async with self._lock: + values = self._categories[key] + self._prune(values, now - window_seconds) + if len(values) >= limit: + raise RateLimitError(max(1, math.ceil(values[0] + window_seconds - now))) + values.append(now) + await self._record(context.api_key_id, category, 1, 0, window_seconds) + @staticmethod def _prune(values: deque[float], cutoff: float) -> None: while values and values[0] <= cutoff: diff --git a/app/security/scopes.py b/app/security/scopes.py index f9f52893618ce2872325e56f0db2015801a6a4e4..890ddc0ad73d9bf99fc9e562b32c301692346cd1 100644 --- a/app/security/scopes.py +++ b/app/security/scopes.py @@ -17,6 +17,14 @@ ALL_SCOPES = frozenset( "mcp:read", "mcp:execute", "system:read", + "social:accounts:read", + "social:accounts:write", + "social:posts:read", + "social:posts:write", + "social:posts:publish", + "social:schedules:read", + "social:schedules:write", + "social:analytics:read", "admin", } ) @@ -37,6 +45,14 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = { "mcp:read", "mcp:execute", "system:read", + "social:accounts:read", + "social:accounts:write", + "social:posts:read", + "social:posts:write", + "social:posts:publish", + "social:schedules:read", + "social:schedules:write", + "social:analytics:read", } ), "operator": frozenset( @@ -52,6 +68,14 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = { "mcp:read", "mcp:execute", "system:read", + "social:accounts:read", + "social:accounts:write", + "social:posts:read", + "social:posts:write", + "social:posts:publish", + "social:schedules:read", + "social:schedules:write", + "social:analytics:read", } ), "viewer": frozenset( @@ -62,6 +86,10 @@ DEFAULT_ROLE_SCOPES: dict[str, frozenset[str]] = { "assets:read", "mcp:read", "system:read", + "social:accounts:read", + "social:posts:read", + "social:schedules:read", + "social:analytics:read", } ), } diff --git a/app/social/__init__.py b/app/social/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6597c9e5d904dbcc723ebae9e46084f8ea514ae0 --- /dev/null +++ b/app/social/__init__.py @@ -0,0 +1,5 @@ +"""MediaRouter Social Automation bounded domain.""" + +from app.social.services.social_service import SocialService + +__all__ = ["SocialService"] diff --git a/app/social/database.py b/app/social/database.py new file mode 100644 index 0000000000000000000000000000000000000000..6bbc36c36ca97bea9624b930a39c915922045523 --- /dev/null +++ b/app/social/database.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from sqlalchemy import event, inspect, text +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from app.core.config import Settings +from app.social.models import SocialBase + +REQUIRED_SOCIAL_TABLES = frozenset( + { + "social_accounts", + "social_account_tokens", + "social_account_capabilities", + "media_variants", + "social_media_assets", + "social_campaigns", + "social_posts", + "social_post_targets", + "social_post_media", + "social_schedules", + "social_jobs", + "social_job_attempts", + "oauth_states", + "social_webhook_events", + "social_post_metrics", + "social_audit_events", + } +) + + +class SocialDatabase: + """Social persistence with migration-only production schema changes.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.database_url = settings.resolved_social_database_url + self.engine: AsyncEngine = create_async_engine(self.database_url, pool_pre_ping=True) + if self.database_url.startswith("sqlite"): + event.listen(self.engine.sync_engine, "connect", self._configure_sqlite) + self.session_factory = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + + @staticmethod + def _configure_sqlite(dbapi_connection: object, _record: object) -> None: + cursor = dbapi_connection.cursor() # type: ignore[attr-defined] + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() + + async def initialize(self) -> None: + if self.settings.social_auto_migrate: + async with self.engine.begin() as connection: + await connection.run_sync(SocialBase.metadata.create_all) + + async def schema_ready(self) -> bool: + """Check the complete Phase 1 schema without changing the database.""" + async with self.engine.connect() as connection: + tables = await connection.run_sync( + lambda sync: set(inspect(sync).get_table_names()) + ) + return REQUIRED_SOCIAL_TABLES.issubset(tables) + + async def missing_tables(self) -> list[str]: + """Return absent required tables for an actionable startup warning.""" + async with self.engine.connect() as connection: + tables = await connection.run_sync( + lambda sync: set(inspect(sync).get_table_names()) + ) + return sorted(REQUIRED_SOCIAL_TABLES - tables) + + async def close(self) -> None: + await self.engine.dispose() + + @asynccontextmanager + async def session(self, workspace_id: str | None = None) -> AsyncIterator[AsyncSession]: + async with self.session_factory() as session: + if workspace_id and self.database_url.startswith(("postgresql", "postgres")): + # RLS policies read this transaction-local tenant identity. + await session.execute( + text("select set_config('app.workspace_id', :workspace_id, true)"), + {"workspace_id": workspace_id}, + ) + yield session diff --git a/app/social/domain/__init__.py b/app/social/domain/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f537bc94a025da0e2b0b186725463467f0a4ada3 --- /dev/null +++ b/app/social/domain/__init__.py @@ -0,0 +1,12 @@ +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, JobStatus, PostStatus, Provider +from app.social.domain.errors import SocialError + +__all__ = [ + "ConnectionStrategy", + "JobStatus", + "PostStatus", + "Provider", + "ProviderCapabilities", + "SocialError", +] diff --git a/app/social/domain/capabilities.py b/app/social/domain/capabilities.py new file mode 100644 index 0000000000000000000000000000000000000000..de5edc41d46caf2391835f8d8a0f6ca536d67ad0 --- /dev/null +++ b/app/social/domain/capabilities.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from app.social.domain.enums import ConnectionStrategy, Provider + + +class ProviderCapabilities(BaseModel): + """Provider metadata consumed by every transport and UI.""" + + model_config = ConfigDict(extra="forbid") + + provider: Provider + connection_strategy: ConnectionStrategy + video: bool = False + video_upload: bool = False + video_status: bool = False + channel_metadata: bool = False + image: bool = False + carousel: bool = False + direct_publish: bool = False + draft_upload: bool = False + scheduled_publish: bool = False + native_scheduling: bool = False + analytics: bool = False + delete_post: bool = False + personal_publishing: bool = False + organization_publishing: bool = False + implementation_status: str = "foundation" + account_types: list[str] = Field(default_factory=list) + required_scopes: list[str] = Field(default_factory=list) + optional_scopes: list[str] = Field(default_factory=list) + # Publishing access is requested only after an explicit user action. This + # prevents provider-product approval scopes from being silently added to a + # foundation/account-discovery connection. + publishing_required_scopes: list[str] = Field(default_factory=list) + # Additional authorization is always opt-in. These scopes are never + # appended to a normal OAuth connection request. + analytics_required_scopes: list[str] = Field(default_factory=list) + # Transport-neutral metadata consumed by dynamic clients. Provider-owned + # runtime choices are fetched from the account publish-options endpoint. + publish_metadata_schema: dict[str, object] = Field(default_factory=dict) + + @property + def publish_supported(self) -> bool: + return self.direct_publish or self.draft_upload diff --git a/app/social/domain/enums.py b/app/social/domain/enums.py new file mode 100644 index 0000000000000000000000000000000000000000..4444744740487074fea67f20eb968ebf0cd199e2 --- /dev/null +++ b/app/social/domain/enums.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from enum import StrEnum + + +class Provider(StrEnum): + YOUTUBE = "youtube" + FACEBOOK = "facebook" + INSTAGRAM = "instagram" + TIKTOK = "tiktok" + X = "x" + LINKEDIN = "linkedin" + TELEGRAM = "telegram" + WHATSAPP = "whatsapp" + + +class ConnectionStrategy(StrEnum): + OAUTH = "oauth" + TOKEN_BOT = "token_bot" + BUSINESS_API = "business_api" + + +class AccountStatus(StrEnum): + PENDING = "pending" + CONNECTED = "connected" + REAUTH_REQUIRED = "reauth_required" + DISCONNECTED = "disconnected" + ERROR = "error" + + +class PostStatus(StrEnum): + DRAFT = "draft" + SCHEDULED = "scheduled" + QUEUED = "queued" + PREPARING = "preparing" + PROCESSING = "processing" + UPLOADING = "uploading" + PUBLISHING = "publishing" + PUBLISHED = "published" + PARTIAL_SUCCESS = "partial_success" + RETRYING = "retrying" + FAILED = "failed" + CANCELLED = "cancelled" + + +class JobStatus(StrEnum): + DRAFT = "draft" + SCHEDULED = "scheduled" + QUEUED = "queued" + PREPARING = "preparing" + PROCESSING = "processing" + UPLOADING = "uploading" + PUBLISHING = "publishing" + PUBLISHED = "published" + RETRYING = "retrying" + FAILED = "failed" + CANCELLED = "cancelled" + + +class PublishMode(StrEnum): + NOW = "now" + SCHEDULE = "schedule" + DRAFT = "draft" + + +TERMINAL_JOB_STATUSES = frozenset( + {JobStatus.PUBLISHED, JobStatus.FAILED, JobStatus.CANCELLED} +) diff --git a/app/social/domain/errors.py b/app/social/domain/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..06cf3f8297b62f6847311f9301a5d8c5ac4c2112 --- /dev/null +++ b/app/social/domain/errors.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from app.core.exceptions import MediaAPIError + + +class SocialError(MediaAPIError): + code = "SOCIAL_ERROR" + status_code = 400 + + +class SocialProviderUnavailableError(SocialError): + code = "SOCIAL_PROVIDER_UNAVAILABLE" + status_code = 503 + + +class SocialProviderNotImplementedError(SocialError): + code = "SOCIAL_PROVIDER_NOT_IMPLEMENTED" + status_code = 501 + + +class SocialAccountNotFoundError(SocialError): + code = "SOCIAL_ACCOUNT_NOT_FOUND" + status_code = 404 + + +class SocialAccountDisconnectedError(SocialError): + code = "SOCIAL_ACCOUNT_DISCONNECTED" + status_code = 409 + + +class SocialReauthRequiredError(SocialError): + code = "SOCIAL_REAUTH_REQUIRED" + status_code = 401 + + +class SocialPermissionDeniedError(SocialError): + code = "SOCIAL_PERMISSION_DENIED" + status_code = 403 + + +class SocialCapabilityUnsupportedError(SocialError): + code = "SOCIAL_CAPABILITY_UNSUPPORTED" + status_code = 422 + + +class SocialMediaInvalidError(SocialError): + code = "SOCIAL_MEDIA_INVALID" + status_code = 422 + + +class SocialRateLimitedError(SocialError): + code = "SOCIAL_RATE_LIMITED" + status_code = 429 + + +class SocialProviderQuotaError(SocialError): + code = "SOCIAL_PROVIDER_QUOTA_EXCEEDED" + status_code = 429 + + +class SocialPostNotFoundError(SocialError): + code = "SOCIAL_POST_NOT_FOUND" + status_code = 404 + + +class SocialJobNotFoundError(SocialError): + code = "SOCIAL_JOB_NOT_FOUND" + status_code = 404 + + +class SocialPublishFailedError(SocialError): + code = "SOCIAL_PUBLISH_FAILED" + status_code = 422 + + +class SocialJobFailedError(SocialError): + code = "SOCIAL_JOB_FAILED" + status_code = 422 + + +class SocialIdempotencyConflictError(SocialError): + code = "SOCIAL_IDEMPOTENCY_CONFLICT" + status_code = 409 + + +class SocialOAuthStateError(SocialError): + code = "SOCIAL_OAUTH_STATE_INVALID" + status_code = 400 + + +class SocialTransitionError(SocialError): + code = "SOCIAL_INVALID_STATE_TRANSITION" + status_code = 409 diff --git a/app/social/domain/models.py b/app/social/domain/models.py new file mode 100644 index 0000000000000000000000000000000000000000..814f78dfb7085047e60257815af91510c112c6fe --- /dev/null +++ b/app/social/domain/models.py @@ -0,0 +1,24 @@ +from app.social.models import ( + MediaVariant, + SocialMediaAsset, + OAuthState, + SocialAccount, + SocialAccountCapability, + SocialAccountToken, + SocialCampaign, + SocialJob, + SocialJobAttempt, + SocialPost, + SocialPostMedia, + SocialPostMetric, + SocialPostTarget, + SocialSchedule, + SocialWebhookEvent, +) + +__all__ = [ + "MediaVariant", "SocialMediaAsset", "OAuthState", "SocialAccount", "SocialAccountCapability", + "SocialAccountToken", "SocialCampaign", "SocialJob", "SocialJobAttempt", + "SocialPost", "SocialPostMedia", "SocialPostMetric", "SocialPostTarget", + "SocialSchedule", "SocialWebhookEvent", +] diff --git a/app/social/domain/retry.py b/app/social/domain/retry.py new file mode 100644 index 0000000000000000000000000000000000000000..9c984af964c6a43ad58487cd8d74675b7b27c588 --- /dev/null +++ b/app/social/domain/retry.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass + +RETRYABLE_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) +BACKOFF_SECONDS = (0, 10, 30, 120, 600) + + +@dataclass(frozen=True, slots=True) +class RetryDecision: + retryable: bool + refresh_token_first: bool = False + delay_seconds: int = 0 + + +def classify_retry(*, status_code: int | None = None, network_error: bool = False, attempt: int = 1) -> RetryDecision: + delay = BACKOFF_SECONDS[min(max(attempt - 1, 0), len(BACKOFF_SECONDS) - 1)] + if network_error or status_code in RETRYABLE_STATUS_CODES: + return RetryDecision(True, delay_seconds=delay) + if status_code == 401: + return RetryDecision(attempt <= 1, refresh_token_first=True, delay_seconds=0) + return RetryDecision(False) diff --git a/app/social/domain/state_machine.py b/app/social/domain/state_machine.py new file mode 100644 index 0000000000000000000000000000000000000000..cd088194b655384b2d8aff126531f8e29d08a316 --- /dev/null +++ b/app/social/domain/state_machine.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from app.social.domain.enums import JobStatus +from app.social.domain.errors import SocialTransitionError + +ALLOWED_TRANSITIONS: dict[JobStatus, frozenset[JobStatus]] = { + JobStatus.DRAFT: frozenset({JobStatus.SCHEDULED, JobStatus.QUEUED, JobStatus.CANCELLED}), + JobStatus.SCHEDULED: frozenset({JobStatus.QUEUED, JobStatus.CANCELLED}), + JobStatus.QUEUED: frozenset({JobStatus.PREPARING, JobStatus.CANCELLED}), + JobStatus.PREPARING: frozenset({JobStatus.PROCESSING, JobStatus.UPLOADING, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}), + JobStatus.PROCESSING: frozenset({JobStatus.UPLOADING, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}), + JobStatus.UPLOADING: frozenset({JobStatus.PUBLISHING, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}), + JobStatus.PUBLISHING: frozenset({JobStatus.PUBLISHED, JobStatus.RETRYING, JobStatus.FAILED, JobStatus.CANCELLED}), + JobStatus.RETRYING: frozenset({JobStatus.PREPARING, JobStatus.PROCESSING, JobStatus.UPLOADING, JobStatus.PUBLISHING, JobStatus.FAILED, JobStatus.CANCELLED}), + JobStatus.PUBLISHED: frozenset(), + JobStatus.FAILED: frozenset(), + JobStatus.CANCELLED: frozenset(), +} + + +def validate_transition(current: str | JobStatus, target: str | JobStatus) -> JobStatus: + source = JobStatus(current) + destination = JobStatus(target) + if destination not in ALLOWED_TRANSITIONS[source]: + raise SocialTransitionError(f"Cannot transition social job from {source} to {destination}.") + return destination diff --git a/app/social/migrations/0001_social_foundation_postgres.sql b/app/social/migrations/0001_social_foundation_postgres.sql new file mode 100644 index 0000000000000000000000000000000000000000..d52f15db534d8fd8464cbe42c975d9fbf36b7c21 --- /dev/null +++ b/app/social/migrations/0001_social_foundation_postgres.sql @@ -0,0 +1,257 @@ +-- MediaRouter Social Automation foundation (PostgreSQL / Supabase) +-- Apply with the normal deployment migration process. Application startup +-- intentionally does not mutate production schema. + +begin; +create extension if not exists pgcrypto; + +create table if not exists social_accounts ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + provider text not null check (provider in ('youtube','facebook','instagram','tiktok','x','linkedin','telegram','whatsapp')), + account_type text not null, + external_account_id text not null, + username text, + display_name text, + avatar_url text, + status text not null default 'pending', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_synced_at timestamptz, + constraint uq_social_account_workspace_provider_external unique (workspace_id, provider, external_account_id) +); +create index if not exists ix_social_accounts_workspace_id on social_accounts(workspace_id); +create index if not exists ix_social_accounts_provider_status on social_accounts(provider, status); + +create table if not exists social_account_tokens ( + id text primary key default gen_random_uuid()::text, + social_account_id text not null references social_accounts(id) on delete cascade, + access_token_secret_id text, + refresh_token_secret_id text, + encrypted_payload text, + expires_at timestamptz, + scopes jsonb not null default '[]'::jsonb, + token_type text, + last_refreshed_at timestamptz, + revoked_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_social_account_token unique (social_account_id), + constraint ck_social_token_storage check ( + encrypted_payload is not null or access_token_secret_id is not null or revoked_at is not null + ) +); +create index if not exists ix_social_account_tokens_expires_at on social_account_tokens(expires_at); + +create table if not exists social_account_capabilities ( + id text primary key default gen_random_uuid()::text, + social_account_id text not null references social_accounts(id) on delete cascade, + capability text not null, + enabled boolean not null default false, + metadata jsonb not null default '{}'::jsonb, + updated_at timestamptz not null default now(), + constraint uq_social_account_capability unique (social_account_id, capability) +); +create index if not exists ix_social_account_capabilities_account on social_account_capabilities(social_account_id); + +create table if not exists media_variants ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + source_asset_id text not null, + asset_reference text, + template_id text, + platform text, + width integer, + height integer, + duration_seconds double precision, + codec text, + container text, + bitrate bigint, + file_size bigint, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); +create index if not exists ix_media_variants_workspace_source on media_variants(workspace_id, source_asset_id); +create index if not exists ix_media_variants_platform on media_variants(platform); + +create table if not exists social_campaigns ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + name text not null, + description text, + status text not null default 'draft', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index if not exists ix_social_campaigns_workspace_id on social_campaigns(workspace_id); + +create table if not exists social_posts ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + campaign_id text references social_campaigns(id) on delete set null, + media_asset_id text not null, + source_variant_id text references media_variants(id) on delete set null, + status text not null default 'draft', + publish_mode text not null default 'draft' check (publish_mode in ('now','schedule','draft')), + idempotency_key text, + request_fingerprint char(64), + metadata jsonb not null default '{}'::jsonb, + created_by text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + published_at timestamptz, + constraint uq_social_posts_workspace_idempotency unique (workspace_id, idempotency_key) +); +create index if not exists ix_social_posts_workspace_created on social_posts(workspace_id, created_at desc); +create index if not exists ix_social_posts_status on social_posts(status); + +create table if not exists social_post_targets ( + id text primary key default gen_random_uuid()::text, + social_post_id text not null references social_posts(id) on delete cascade, + social_account_id text not null references social_accounts(id) on delete restrict, + provider text not null, + status text not null default 'draft', + caption jsonb not null default '{}'::jsonb, + platform_metadata jsonb not null default '{}'::jsonb, + external_post_id text, + external_url text, + error_code text, + error_message text, + published_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_social_post_target unique (social_post_id, social_account_id) +); +create index if not exists ix_social_post_targets_post on social_post_targets(social_post_id); +create index if not exists ix_social_post_targets_account on social_post_targets(social_account_id); +create index if not exists ix_social_post_targets_status on social_post_targets(status); + +create table if not exists social_post_media ( + id text primary key default gen_random_uuid()::text, + social_post_id text not null references social_posts(id) on delete cascade, + media_variant_id text references media_variants(id) on delete set null, + media_asset_id text, + position integer not null default 0, + kind text not null default 'video', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); +create index if not exists ix_social_post_media_post on social_post_media(social_post_id); + +create table if not exists social_schedules ( + id text primary key default gen_random_uuid()::text, + social_post_id text not null references social_posts(id) on delete cascade, + scheduled_at timestamptz not null, + timezone text not null, + status text not null default 'scheduled', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint uq_social_schedule_post unique (social_post_id) +); +create index if not exists ix_social_schedules_due on social_schedules(status, scheduled_at); + +create table if not exists social_jobs ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + social_post_id text not null references social_posts(id) on delete cascade, + social_post_target_id text references social_post_targets(id) on delete cascade, + provider text, + status text not null default 'queued', + attempt_count integer not null default 0, + max_attempts integer not null default 5, + next_attempt_at timestamptz, + idempotency_key text, + error_code text, + error_message text, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + started_at timestamptz, + completed_at timestamptz, + updated_at timestamptz not null default now(), + constraint uq_social_jobs_workspace_idempotency unique (workspace_id, idempotency_key) +); +create index if not exists ix_social_jobs_workspace_status on social_jobs(workspace_id, status); +create index if not exists ix_social_jobs_next_attempt on social_jobs(status, next_attempt_at); +create index if not exists ix_social_jobs_post on social_jobs(social_post_id); + +create table if not exists social_job_attempts ( + id text primary key default gen_random_uuid()::text, + social_job_id text not null references social_jobs(id) on delete cascade, + attempt_number integer not null, + status text not null, + error_code text, + error_message text, + provider_request_id text, + started_at timestamptz not null default now(), + completed_at timestamptz, + constraint uq_social_job_attempt_number unique (social_job_id, attempt_number) +); +create index if not exists ix_social_job_attempts_job on social_job_attempts(social_job_id, attempt_number); + +create table if not exists oauth_states ( + id text primary key default gen_random_uuid()::text, + state text not null unique, + provider text not null, + workspace_id text not null, + user_id text, + redirect_uri text not null, + code_verifier_encrypted text, + expires_at timestamptz not null, + used_at timestamptz, + created_at timestamptz not null default now() +); +create index if not exists ix_oauth_states_expires_at on oauth_states(expires_at); +create index if not exists ix_oauth_states_workspace on oauth_states(workspace_id); + +create table if not exists social_webhook_events ( + id text primary key default gen_random_uuid()::text, + provider text not null, + event_type text not null, + external_event_id text not null, + workspace_id text, + payload jsonb not null default '{}'::jsonb, + received_at timestamptz not null default now(), + processed_at timestamptz, + status text not null default 'received', + error_message text, + constraint uq_social_webhook_provider_external unique (provider, external_event_id) +); +create index if not exists ix_social_webhook_events_status on social_webhook_events(status); +create index if not exists ix_social_webhook_events_received on social_webhook_events(received_at); + +create table if not exists social_post_metrics ( + id text primary key default gen_random_uuid()::text, + social_post_id text not null references social_posts(id) on delete cascade, + social_post_target_id text references social_post_targets(id) on delete cascade, + provider text not null, + views bigint, + impressions bigint, + likes bigint, + comments bigint, + shares bigint, + engagement_rate double precision, + published_at timestamptz, + retrieved_at timestamptz not null default now(), + raw_metrics jsonb not null default '{}'::jsonb +); +create index if not exists ix_social_post_metrics_target_retrieved on social_post_metrics(social_post_target_id, retrieved_at desc); + +create table if not exists social_audit_events ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + api_key_id text, + event_type text not null, + provider text, + social_account_id text, + social_post_id text, + social_job_id text, + request_id text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); +create index if not exists ix_social_audit_events_workspace_created on social_audit_events(workspace_id, created_at desc); +create index if not exists ix_social_audit_events_type on social_audit_events(event_type); + +commit; diff --git a/app/social/migrations/0002_social_rls.sql b/app/social/migrations/0002_social_rls.sql new file mode 100644 index 0000000000000000000000000000000000000000..d47d66df2088ae5e4d8a2160c24932235ea39faf --- /dev/null +++ b/app/social/migrations/0002_social_rls.sql @@ -0,0 +1,90 @@ +-- Tenant isolation policies for the social bounded domain. +-- The API starts each Postgres transaction with: +-- set_config('app.workspace_id', '', true) +-- Supabase service-role workers bypass RLS. Never expose that credential. + +begin; + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'social_accounts', 'media_variants', 'social_campaigns', 'social_posts', + 'social_jobs', 'social_webhook_events', 'social_audit_events' + ] loop + execute format('alter table %I enable row level security', table_name); + execute format('drop policy if exists social_workspace_isolation on %I', table_name); + execute format( + 'create policy social_workspace_isolation on %I using (workspace_id = current_setting(''app.workspace_id'', true)) with check (workspace_id = current_setting(''app.workspace_id'', true))', + table_name + ); + end loop; +end $$; + +-- OAuth states are never a user-facing resource. The provider callback has no +-- authenticated workspace context, so its random, expiring, single-use state +-- token is the isolation boundary. Enabling RLS here would reject a valid +-- callback before it can atomically consume that state. +alter table oauth_states disable row level security; + +alter table social_account_tokens enable row level security; +drop policy if exists social_token_workspace_isolation on social_account_tokens; +create policy social_token_workspace_isolation on social_account_tokens +using (exists ( + select 1 from social_accounts a + where a.id = social_account_tokens.social_account_id + and a.workspace_id = current_setting('app.workspace_id', true) +)) with check (exists ( + select 1 from social_accounts a + where a.id = social_account_tokens.social_account_id + and a.workspace_id = current_setting('app.workspace_id', true) +)); + +alter table social_account_capabilities enable row level security; +drop policy if exists social_capability_workspace_isolation on social_account_capabilities; +create policy social_capability_workspace_isolation on social_account_capabilities +using (exists ( + select 1 from social_accounts a + where a.id = social_account_capabilities.social_account_id + and a.workspace_id = current_setting('app.workspace_id', true) +)) with check (exists ( + select 1 from social_accounts a + where a.id = social_account_capabilities.social_account_id + and a.workspace_id = current_setting('app.workspace_id', true) +)); + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'social_post_targets', 'social_post_media', 'social_schedules', 'social_post_metrics' + ] loop + execute format('alter table %I enable row level security', table_name); + execute format('drop policy if exists social_post_child_isolation on %I', table_name); + execute format( + 'create policy social_post_child_isolation on %I using (exists (select 1 from social_posts p where p.id = %I.social_post_id and p.workspace_id = current_setting(''app.workspace_id'', true))) with check (exists (select 1 from social_posts p where p.id = %I.social_post_id and p.workspace_id = current_setting(''app.workspace_id'', true)))', + table_name, table_name, table_name + ); + end loop; +end $$; + +alter table social_job_attempts enable row level security; +drop policy if exists social_job_attempt_workspace_isolation on social_job_attempts; +create policy social_job_attempt_workspace_isolation on social_job_attempts +using (exists ( + select 1 from social_jobs j + where j.id = social_job_attempts.social_job_id + and j.workspace_id = current_setting('app.workspace_id', true) +)) with check (exists ( + select 1 from social_jobs j + where j.id = social_job_attempts.social_job_id + and j.workspace_id = current_setting('app.workspace_id', true) +)); + +commit; + +-- Reversal (intentionally explicit; execute only during a controlled rollback): +-- ALTER TABLE DISABLE ROW LEVEL SECURITY; DROP POLICY ...; +-- This migration never drops customer data. diff --git a/app/social/migrations/0003_social_integrity_postgres.sql b/app/social/migrations/0003_social_integrity_postgres.sql new file mode 100644 index 0000000000000000000000000000000000000000..8de11804ac8b9e33ad4599193dd6f7a74d8fc365 --- /dev/null +++ b/app/social/migrations/0003_social_integrity_postgres.sql @@ -0,0 +1,187 @@ +-- Social foundation hardening. Apply after 0001 and 0002. +-- This migration is additive: it adds database-side timestamp maintenance and +-- tenant relationship checks without changing or deleting customer data. + +begin; + +create or replace function mediarouter_social_touch_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +do $$ +declare + table_name text; +begin + foreach table_name in array array[ + 'social_accounts', 'social_account_tokens', 'social_account_capabilities', + 'social_campaigns', 'social_posts', 'social_post_targets', + 'social_schedules', 'social_jobs' + ] loop + execute format( + 'drop trigger if exists mediarouter_social_touch_updated_at on %I', + table_name + ); + execute format( + 'create trigger mediarouter_social_touch_updated_at before update on %I for each row execute function mediarouter_social_touch_updated_at()', + table_name + ); + end loop; +end; +$$; + +-- SQLAlchemy enums are represented as text for compatibility with the +-- existing schema. These constraints keep direct SQL writes within the same +-- state vocabulary enforced by the application state machine. +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'ck_social_accounts_status') then + alter table social_accounts add constraint ck_social_accounts_status + check (status in ('pending', 'connected', 'reauth_required', 'disconnected', 'error')); + end if; + if not exists (select 1 from pg_constraint where conname = 'ck_social_posts_status') then + alter table social_posts add constraint ck_social_posts_status + check (status in ('draft', 'scheduled', 'queued', 'preparing', 'processing', 'uploading', 'publishing', 'published', 'partial_success', 'retrying', 'failed', 'cancelled')); + end if; + if not exists (select 1 from pg_constraint where conname = 'ck_social_post_targets_status') then + alter table social_post_targets add constraint ck_social_post_targets_status + check (status in ('draft', 'scheduled', 'queued', 'preparing', 'processing', 'uploading', 'publishing', 'published', 'partial_success', 'retrying', 'failed', 'cancelled')); + end if; + if not exists (select 1 from pg_constraint where conname = 'ck_social_schedules_status') then + alter table social_schedules add constraint ck_social_schedules_status + check (status in ('scheduled', 'queued', 'cancelled')); + end if; + if not exists (select 1 from pg_constraint where conname = 'ck_social_jobs_status') then + alter table social_jobs add constraint ck_social_jobs_status + check (status in ('draft', 'scheduled', 'queued', 'preparing', 'processing', 'uploading', 'publishing', 'published', 'retrying', 'failed', 'cancelled')); + end if; + if not exists (select 1 from pg_constraint where conname = 'ck_social_jobs_attempt_bounds') then + alter table social_jobs add constraint ck_social_jobs_attempt_bounds + check (attempt_count >= 0 and max_attempts >= 0); + end if; +end; +$$; + +create or replace function mediarouter_social_assert_workspace_integrity() +returns trigger +language plpgsql +as $$ +declare + post_workspace text; + related_workspace text; + related_provider text; + related_post_id text; +begin + if tg_op = 'UPDATE' and tg_table_name in ('social_posts', 'social_jobs') + and new.workspace_id is distinct from old.workspace_id then + raise exception 'social workspace cannot be reassigned' + using errcode = '23514'; + end if; + + if tg_table_name = 'social_posts' then + if new.campaign_id is not null then + select workspace_id into related_workspace + from social_campaigns where id = new.campaign_id; + if related_workspace is distinct from new.workspace_id then + raise exception 'social post campaign must belong to the same workspace' + using errcode = '23503'; + end if; + end if; + if new.source_variant_id is not null then + select workspace_id into related_workspace + from media_variants where id = new.source_variant_id; + if related_workspace is distinct from new.workspace_id then + raise exception 'social post variant must belong to the same workspace' + using errcode = '23503'; + end if; + end if; + elsif tg_table_name = 'social_post_targets' then + select workspace_id into post_workspace + from social_posts where id = new.social_post_id; + select workspace_id, provider into related_workspace, related_provider + from social_accounts where id = new.social_account_id; + if post_workspace is null or related_workspace is distinct from post_workspace then + raise exception 'social post target account must belong to the post workspace' + using errcode = '23503'; + end if; + if new.provider is distinct from related_provider then + raise exception 'social post target provider must match its account' + using errcode = '23514'; + end if; + elsif tg_table_name = 'social_post_media' and new.media_variant_id is not null then + select workspace_id into post_workspace + from social_posts where id = new.social_post_id; + select workspace_id into related_workspace + from media_variants where id = new.media_variant_id; + if post_workspace is null or related_workspace is distinct from post_workspace then + raise exception 'social post media variant must belong to the post workspace' + using errcode = '23503'; + end if; + elsif tg_table_name = 'social_jobs' then + select workspace_id into post_workspace + from social_posts where id = new.social_post_id; + if post_workspace is distinct from new.workspace_id then + raise exception 'social job must belong to the post workspace' + using errcode = '23503'; + end if; + if new.social_post_target_id is not null then + select social_post_id, provider into related_post_id, related_provider + from social_post_targets where id = new.social_post_target_id; + if related_post_id is distinct from new.social_post_id then + raise exception 'social job target must belong to the social post' + using errcode = '23503'; + end if; + if new.provider is not null and new.provider is distinct from related_provider then + raise exception 'social job provider must match its target' + using errcode = '23514'; + end if; + end if; + elsif tg_table_name = 'social_post_metrics' then + if new.social_post_target_id is not null then + select social_post_id, provider into related_post_id, related_provider + from social_post_targets where id = new.social_post_target_id; + if related_post_id is distinct from new.social_post_id then + raise exception 'social metric target must belong to the social post' + using errcode = '23503'; + end if; + if new.provider is distinct from related_provider then + raise exception 'social metric provider must match its target' + using errcode = '23514'; + end if; + end if; + end if; + return new; +end; +$$; + +drop trigger if exists mediarouter_social_post_workspace_integrity on social_posts; +create trigger mediarouter_social_post_workspace_integrity +before insert or update of workspace_id, campaign_id, source_variant_id on social_posts +for each row execute function mediarouter_social_assert_workspace_integrity(); + +drop trigger if exists mediarouter_social_target_workspace_integrity on social_post_targets; +create trigger mediarouter_social_target_workspace_integrity +before insert or update of social_post_id, social_account_id, provider on social_post_targets +for each row execute function mediarouter_social_assert_workspace_integrity(); + +drop trigger if exists mediarouter_social_post_media_workspace_integrity on social_post_media; +create trigger mediarouter_social_post_media_workspace_integrity +before insert or update of social_post_id, media_variant_id on social_post_media +for each row execute function mediarouter_social_assert_workspace_integrity(); + +drop trigger if exists mediarouter_social_job_workspace_integrity on social_jobs; +create trigger mediarouter_social_job_workspace_integrity +before insert or update of workspace_id, social_post_id, social_post_target_id, provider on social_jobs +for each row execute function mediarouter_social_assert_workspace_integrity(); + +drop trigger if exists mediarouter_social_metric_workspace_integrity on social_post_metrics; +create trigger mediarouter_social_metric_workspace_integrity +before insert or update of social_post_id, social_post_target_id, provider on social_post_metrics +for each row execute function mediarouter_social_assert_workspace_integrity(); + +commit; diff --git a/app/social/migrations/0004_youtube_media_assets.sql b/app/social/migrations/0004_youtube_media_assets.sql new file mode 100644 index 0000000000000000000000000000000000000000..433604f728e61e345036a460857359d4213f6b12 --- /dev/null +++ b/app/social/migrations/0004_youtube_media_assets.sql @@ -0,0 +1,28 @@ +-- YouTube Phase 2: durable, tenant-scoped references to MediaRouter outputs +-- plus confidential durable state for resumable Google upload sessions. +-- This migration is additive and does not rewrite Phase 1 records. + +begin; + +create table if not exists social_media_assets ( + id text primary key default gen_random_uuid()::text, + workspace_id text not null, + request_id text not null, + filename text not null, + mime_type text not null, + file_size bigint not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + constraint uq_social_media_asset_workspace_output unique (workspace_id, request_id, filename) +); +create index if not exists ix_social_media_assets_workspace_id on social_media_assets(workspace_id); + +alter table social_media_assets enable row level security; +drop policy if exists social_workspace_isolation on social_media_assets; +create policy social_workspace_isolation on social_media_assets + using (workspace_id = current_setting('app.workspace_id', true)) + with check (workspace_id = current_setting('app.workspace_id', true)); + +alter table social_jobs add column if not exists provider_state_encrypted text; + +commit; diff --git a/app/social/models.py b/app/social/models.py new file mode 100644 index 0000000000000000000000000000000000000000..60a57c86848fd528fd70cd795c71d33a908b9551 --- /dev/null +++ b/app/social/models.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import ( + JSON, + BigInteger, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def new_id() -> str: + return str(uuid4()) + + +class SocialBase(DeclarativeBase): + """Separate metadata keeps production schema changes migration-only.""" + + +class SocialAccount(SocialBase): + __tablename__ = "social_accounts" + __table_args__ = ( + UniqueConstraint("workspace_id", "provider", "external_account_id", name="uq_social_account_workspace_provider_external"), + Index("ix_social_accounts_workspace_id", "workspace_id"), + Index("ix_social_accounts_provider_status", "provider", "status"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + account_type: Mapped[str] = mapped_column(String(64), nullable=False) + external_account_id: Mapped[str] = mapped_column(String(255), nullable=False) + username: Mapped[str | None] = mapped_column(String(255)) + display_name: Mapped[str | None] = mapped_column(String(255)) + avatar_url: Mapped[str | None] = mapped_column(String(2048)) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class SocialAccountToken(SocialBase): + __tablename__ = "social_account_tokens" + __table_args__ = ( + UniqueConstraint("social_account_id", name="uq_social_account_token"), + Index("ix_social_account_tokens_expires_at", "expires_at"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_account_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False) + access_token_secret_id: Mapped[str | None] = mapped_column(String(255)) + refresh_token_secret_id: Mapped[str | None] = mapped_column(String(255)) + encrypted_payload: Mapped[str | None] = mapped_column(Text) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) + token_type: Mapped[str | None] = mapped_column(String(64)) + last_refreshed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + +class SocialAccountCapability(SocialBase): + __tablename__ = "social_account_capabilities" + __table_args__ = ( + UniqueConstraint("social_account_id", "capability", name="uq_social_account_capability"), + Index("ix_social_account_capabilities_account", "social_account_id"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_account_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_accounts.id", ondelete="CASCADE"), nullable=False) + capability: Mapped[str] = mapped_column(String(100), nullable=False) + enabled: Mapped[bool] = mapped_column(nullable=False, default=False) + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + +class MediaVariant(SocialBase): + __tablename__ = "media_variants" + __table_args__ = ( + Index("ix_media_variants_workspace_source", "workspace_id", "source_asset_id"), + Index("ix_media_variants_platform", "platform"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + source_asset_id: Mapped[str] = mapped_column(String(255), nullable=False) + asset_reference: Mapped[str | None] = mapped_column(String(2048)) + template_id: Mapped[str | None] = mapped_column(String(255)) + platform: Mapped[str | None] = mapped_column(String(32)) + width: Mapped[int | None] = mapped_column(Integer) + height: Mapped[int | None] = mapped_column(Integer) + duration_seconds: Mapped[float | None] = mapped_column() + codec: Mapped[str | None] = mapped_column(String(64)) + container: Mapped[str | None] = mapped_column(String(64)) + bitrate: Mapped[int | None] = mapped_column(BigInteger) + file_size: Mapped[int | None] = mapped_column(BigInteger) + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class SocialMediaAsset(SocialBase): + """A workspace-owned reference to an output published by MediaRouter. + + The media processor remains the source of truth for files. This table + only records a tenant binding and immutable download locator so social + workers can validate and stream the exact output without trusting a + caller-supplied filesystem path or URL. + """ + + __tablename__ = "social_media_assets" + __table_args__ = ( + UniqueConstraint("workspace_id", "request_id", "filename", name="uq_social_media_asset_workspace_output"), + Index("ix_social_media_assets_workspace_id", "workspace_id"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + request_id: Mapped[str] = mapped_column(String(36), nullable=False) + filename: Mapped[str] = mapped_column(String(255), nullable=False) + mime_type: Mapped[str] = mapped_column(String(255), nullable=False) + file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class SocialCampaign(SocialBase): + __tablename__ = "social_campaigns" + __table_args__ = (Index("ix_social_campaigns_workspace_id", "workspace_id"),) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + +class SocialPost(SocialBase): + __tablename__ = "social_posts" + __table_args__ = ( + UniqueConstraint("workspace_id", "idempotency_key", name="uq_social_posts_workspace_idempotency"), + Index("ix_social_posts_workspace_created", "workspace_id", "created_at"), + Index("ix_social_posts_status", "status"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + campaign_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("social_campaigns.id", ondelete="SET NULL")) + media_asset_id: Mapped[str] = mapped_column(String(255), nullable=False) + source_variant_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_variants.id", ondelete="SET NULL")) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + publish_mode: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + idempotency_key: Mapped[str | None] = mapped_column(String(255)) + request_fingerprint: Mapped[str | None] = mapped_column(String(64)) + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_by: Mapped[str | None] = mapped_column(String(120)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class SocialPostTarget(SocialBase): + __tablename__ = "social_post_targets" + __table_args__ = ( + UniqueConstraint("social_post_id", "social_account_id", name="uq_social_post_target"), + Index("ix_social_post_targets_post", "social_post_id"), + Index("ix_social_post_targets_account", "social_account_id"), + Index("ix_social_post_targets_status", "status"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False) + social_account_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_accounts.id", ondelete="RESTRICT"), nullable=False) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft") + caption_json: Mapped[dict[str, object]] = mapped_column("caption", JSON, nullable=False, default=dict) + platform_metadata: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) + external_post_id: Mapped[str | None] = mapped_column(String(255)) + external_url: Mapped[str | None] = mapped_column(String(2048)) + error_code: Mapped[str | None] = mapped_column(String(100)) + error_message: Mapped[str | None] = mapped_column(Text) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + +class SocialPostMedia(SocialBase): + __tablename__ = "social_post_media" + __table_args__ = (Index("ix_social_post_media_post", "social_post_id"),) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False) + media_variant_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("media_variants.id", ondelete="SET NULL")) + media_asset_id: Mapped[str | None] = mapped_column(String(255)) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + kind: Mapped[str] = mapped_column(String(32), nullable=False, default="video") + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class SocialSchedule(SocialBase): + __tablename__ = "social_schedules" + __table_args__ = ( + UniqueConstraint("social_post_id", name="uq_social_schedule_post"), + Index("ix_social_schedules_due", "status", "scheduled_at"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False) + scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + timezone: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="scheduled") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + +class SocialJob(SocialBase): + __tablename__ = "social_jobs" + __table_args__ = ( + UniqueConstraint("workspace_id", "idempotency_key", name="uq_social_jobs_workspace_idempotency"), + Index("ix_social_jobs_workspace_status", "workspace_id", "status"), + Index("ix_social_jobs_next_attempt", "status", "next_attempt_at"), + Index("ix_social_jobs_post", "social_post_id"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False) + social_post_target_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE")) + provider: Mapped[str | None] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5) + next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + idempotency_key: Mapped[str | None] = mapped_column(String(255)) + error_code: Mapped[str | None] = mapped_column(String(100)) + error_message: Mapped[str | None] = mapped_column(Text) + payload_json: Mapped[dict[str, object]] = mapped_column("payload", JSON, nullable=False, default=dict) + # Provider resumable-session URLs are bearer-like credentials. They must + # survive a worker restart but must never be present in job REST/MCP/SDK + # payloads, so they are encrypted separately from payload JSON. + provider_state_encrypted: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow) + + +class SocialJobAttempt(SocialBase): + __tablename__ = "social_job_attempts" + __table_args__ = ( + UniqueConstraint("social_job_id", "attempt_number", name="uq_social_job_attempt_number"), + Index("ix_social_job_attempts_job", "social_job_id", "attempt_number"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_job_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_jobs.id", ondelete="CASCADE"), nullable=False) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False) + error_code: Mapped[str | None] = mapped_column(String(100)) + error_message: Mapped[str | None] = mapped_column(Text) + provider_request_id: Mapped[str | None] = mapped_column(String(255)) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class OAuthState(SocialBase): + __tablename__ = "oauth_states" + __table_args__ = ( + Index("ix_oauth_states_state", "state", unique=True), + Index("ix_oauth_states_expires_at", "expires_at"), + Index("ix_oauth_states_workspace", "workspace_id"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + state: Mapped[str] = mapped_column(String(255), nullable=False) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + user_id: Mapped[str | None] = mapped_column(String(120)) + redirect_uri: Mapped[str] = mapped_column(String(2048), nullable=False) + code_verifier_encrypted: Mapped[str | None] = mapped_column(Text) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + + +class SocialWebhookEvent(SocialBase): + __tablename__ = "social_webhook_events" + __table_args__ = ( + UniqueConstraint("provider", "external_event_id", name="uq_social_webhook_provider_external"), + Index("ix_social_webhook_events_status", "status"), + Index("ix_social_webhook_events_received", "received_at"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + external_event_id: Mapped[str] = mapped_column(String(255), nullable=False) + workspace_id: Mapped[str | None] = mapped_column(String(120)) + payload_json: Mapped[dict[str, object]] = mapped_column("payload", JSON, nullable=False, default=dict) + received_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="received") + error_message: Mapped[str | None] = mapped_column(Text) + + +class SocialPostMetric(SocialBase): + __tablename__ = "social_post_metrics" + __table_args__ = (Index("ix_social_post_metrics_target_retrieved", "social_post_target_id", "retrieved_at"),) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + social_post_id: Mapped[str] = mapped_column(String(36), ForeignKey("social_posts.id", ondelete="CASCADE"), nullable=False) + social_post_target_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("social_post_targets.id", ondelete="CASCADE")) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + views: Mapped[int | None] = mapped_column(BigInteger) + impressions: Mapped[int | None] = mapped_column(BigInteger) + likes: Mapped[int | None] = mapped_column(BigInteger) + comments: Mapped[int | None] = mapped_column(BigInteger) + shares: Mapped[int | None] = mapped_column(BigInteger) + engagement_rate: Mapped[float | None] = mapped_column() + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + retrieved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) + raw_metrics: Mapped[dict[str, object]] = mapped_column(JSON, nullable=False, default=dict) + + +class SocialAuditEvent(SocialBase): + __tablename__ = "social_audit_events" + __table_args__ = ( + Index("ix_social_audit_events_workspace_created", "workspace_id", "created_at"), + Index("ix_social_audit_events_type", "event_type"), + ) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + workspace_id: Mapped[str] = mapped_column(String(120), nullable=False) + api_key_id: Mapped[str | None] = mapped_column(String(36)) + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + provider: Mapped[str | None] = mapped_column(String(32)) + social_account_id: Mapped[str | None] = mapped_column(String(36)) + social_post_id: Mapped[str | None] = mapped_column(String(36)) + social_job_id: Mapped[str | None] = mapped_column(String(36)) + request_id: Mapped[str | None] = mapped_column(String(64)) + metadata_json: Mapped[dict[str, object]] = mapped_column("metadata", JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=utcnow) diff --git a/app/social/oauth/__init__.py b/app/social/oauth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..67541065c30282e0234cd13fb1a09af140f72792 --- /dev/null +++ b/app/social/oauth/__init__.py @@ -0,0 +1,4 @@ +from app.social.oauth.encryption import TokenCipher +from app.social.oauth.state import OAuthStateService + +__all__ = ["OAuthStateService", "TokenCipher"] diff --git a/app/social/oauth/base.py b/app/social/oauth/base.py new file mode 100644 index 0000000000000000000000000000000000000000..c83b4b1abdca57d2e2f2a07c1c2c32fdac04c0f6 --- /dev/null +++ b/app/social/oauth/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Protocol + + +class OAuthProvider(Protocol): + async def get_authorization_url(self, *, state: str, redirect_uri: str) -> str: + ... + + async def exchange_code( + self, *, code: str, redirect_uri: str + ) -> dict[str, object]: + ... + + async def refresh_token(self, token: dict[str, object]) -> dict[str, object]: + ... + + async def revoke_token(self, token: dict[str, object]) -> None: + ... diff --git a/app/social/oauth/encryption.py b/app/social/oauth/encryption.py new file mode 100644 index 0000000000000000000000000000000000000000..53c89f351686631001111024df5a024958cf43ab --- /dev/null +++ b/app/social/oauth/encryption.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +from typing import Any + +from app.social.domain.errors import SocialProviderUnavailableError + + +class TokenCipher: + """Narrow encryption boundary for non-Vault local development. + + Imports cryptography lazily so provider discovery and existing media APIs + still start when social token storage is unused. Production should use + Supabase Vault references instead of this encrypted database fallback. + """ + + def __init__(self, key: str | None) -> None: + self._key = key + + def _fernet(self): + if not self._key: + raise SocialProviderUnavailableError( + "SOCIAL_OAUTH_ENCRYPTION_KEY is required when Supabase Vault is disabled." + ) + try: + from cryptography.fernet import Fernet + except ImportError as exc: + raise SocialProviderUnavailableError("The cryptography package is required for token storage.") from exc + digest = hashlib.sha256(self._key.encode("utf-8")).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + def encrypt(self, value: dict[str, Any]) -> str: + return self._fernet().encrypt(json.dumps(value, separators=(",", ":")).encode()).decode() + + def decrypt(self, value: str) -> dict[str, Any]: + result = json.loads(self._fernet().decrypt(value.encode()).decode()) + return result if isinstance(result, dict) else {} diff --git a/app/social/oauth/state.py b/app/social/oauth/state.py new file mode 100644 index 0000000000000000000000000000000000000000..38c6488fc64c9266eb7d9690bdcaa59fbc0e3c08 --- /dev/null +++ b/app/social/oauth/state.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import secrets +from datetime import datetime, timedelta, timezone + +from sqlalchemy import update + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialOAuthStateError +from app.social.models import OAuthState +from app.social.oauth.encryption import TokenCipher + + +class OAuthStateService: + def __init__(self, database: SocialDatabase, cipher: TokenCipher) -> None: + self.database = database + self.cipher = cipher + + async def create(self, *, provider: str, workspace_id: str, user_id: str, redirect_uri: str, ttl_seconds: int = 600) -> OAuthState: + record = OAuthState( + state=secrets.token_urlsafe(32), provider=provider, workspace_id=workspace_id, + user_id=user_id, redirect_uri=redirect_uri, + code_verifier_encrypted=self.cipher.encrypt({"verifier": secrets.token_urlsafe(48)}), + expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds), + ) + async with self.database.session(workspace_id) as session: + session.add(record) + await session.commit() + await session.refresh(record) + return record + + async def consume(self, *, state: str, provider: str) -> OAuthState: + now = datetime.now(timezone.utc) + async with self.database.session() as session: + record = await session.scalar( + update(OAuthState) + .where( + OAuthState.state == state, + OAuthState.provider == provider, + OAuthState.used_at.is_(None), + OAuthState.expires_at > now, + ) + .values(used_at=now) + .returning(OAuthState) + ) + if record is None: + raise SocialOAuthStateError("OAuth state is invalid, expired, or already used.") + await session.commit() + return record + + def code_verifier(self, record: OAuthState) -> str | None: + if not record.code_verifier_encrypted: + return None + value = self.cipher.decrypt(record.code_verifier_encrypted) + verifier = value.get("verifier") + return str(verifier) if verifier else None diff --git a/app/social/providers/__init__.py b/app/social/providers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f73b7a653beb37a198e5ef5ebd34ded0e8598058 --- /dev/null +++ b/app/social/providers/__init__.py @@ -0,0 +1,3 @@ +from app.social.providers.registry import ProviderRegistry, build_provider_registry + +__all__ = ["ProviderRegistry", "build_provider_registry"] diff --git a/app/social/providers/base.py b/app/social/providers/base.py new file mode 100644 index 0000000000000000000000000000000000000000..48a933652c39b38f532d103b3a69457a687b3284 --- /dev/null +++ b/app/social/providers/base.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from abc import ABC +from typing import Any + +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.errors import ( + SocialCapabilityUnsupportedError, + SocialProviderNotImplementedError, + SocialPublishFailedError, +) + + +class SocialProviderAdapter(ABC): + """Common adapter contract; unsupported functions fail explicitly.""" + + capabilities: ProviderCapabilities + # Adapters opt out only when their official web OAuth contract does not + # define PKCE. OAuthService remains the single orchestration layer. + pkce_supported: bool = True + reconciliation_poll_seconds: int = 30 + + @property + def provider(self) -> str: + return self.capabilities.provider.value + + async def get_authorization_url( + self, + *, + state: str, + redirect_uri: str, + code_challenge: str | None = None, + additional_scopes: list[str] | None = None, + ) -> str: + raise SocialProviderNotImplementedError(f"{self.provider} account connection is not implemented.") + + async def exchange_code( + self, *, code: str, redirect_uri: str, code_verifier: str | None = None + ) -> dict[str, Any]: + raise SocialProviderNotImplementedError(f"{self.provider} OAuth exchange is not implemented.") + + async def refresh_token(self, token: dict[str, Any]) -> dict[str, Any]: + raise SocialProviderNotImplementedError(f"{self.provider} token refresh is not implemented.") + + async def revoke_token(self, token: dict[str, Any]) -> None: + raise SocialProviderNotImplementedError(f"{self.provider} token revocation is not implemented.") + + async def get_account(self, token: dict[str, Any]) -> dict[str, Any]: + raise SocialProviderNotImplementedError(f"{self.provider} account discovery is not implemented.") + + async def get_capabilities(self) -> ProviderCapabilities: + return self.capabilities + + async def get_publish_options(self, token: dict[str, Any]) -> dict[str, Any]: + if not self.capabilities.publish_supported: + raise SocialCapabilityUnsupportedError( + f"{self.provider} publishing is unavailable." + ) + raise SocialProviderNotImplementedError( + f"{self.provider} publish options are not implemented." + ) + + async def validate_media(self, media: dict[str, Any]) -> None: + if not self.capabilities.publish_supported: + raise SocialProviderNotImplementedError(f"{self.provider} publishing is not implemented.") + + async def upload_media(self, token: dict[str, Any], media: dict[str, Any]) -> dict[str, Any]: + raise SocialProviderNotImplementedError(f"{self.provider} media upload is not implemented.") + + async def publish(self, token: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + raise SocialProviderNotImplementedError(f"{self.provider} publishing is not implemented.") + + async def get_publish_status(self, token: dict[str, Any], external_id: str) -> dict[str, Any]: + raise SocialProviderNotImplementedError(f"{self.provider} publish status is not implemented.") + + def publish_failure(self, result: dict[str, Any]) -> Exception: + """Normalize an unsuccessful provider status without leaking its payload.""" + del result + return SocialPublishFailedError( + f"{self.provider} publishing did not complete successfully." + ) + + async def delete_post(self, token: dict[str, Any], external_id: str) -> None: + if not self.capabilities.delete_post: + raise SocialCapabilityUnsupportedError(f"{self.provider} does not support post deletion.") + raise SocialProviderNotImplementedError(f"{self.provider} post deletion is not implemented.") + + async def get_metrics(self, token: dict[str, Any], external_id: str) -> dict[str, Any]: + if not self.capabilities.analytics: + raise SocialCapabilityUnsupportedError(f"{self.provider} analytics are unavailable.") + raise SocialProviderNotImplementedError(f"{self.provider} analytics are not implemented.") diff --git a/app/social/providers/facebook.py b/app/social/providers/facebook.py new file mode 100644 index 0000000000000000000000000000000000000000..50ca0383e2bf5bbae316dafc93bf59d1878abf79 --- /dev/null +++ b/app/social/providers/facebook.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx + +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.providers.meta_graph import MetaGraphClient, insight_values +from app.social.providers.oauth import OAuthFoundationAdapter + + +class FacebookProvider(OAuthFoundationAdapter): + + def __init__( + self, settings: Settings, *, http_client: httpx.AsyncClient | None = None + ) -> None: + self.client_id = settings.resolved_meta_app_id + self.authorization_endpoint = ( + f"https://www.facebook.com/{settings.meta_graph_api_version}/dialog/oauth" + ) + self._graph = MetaGraphClient(settings, http_client=http_client) + self.capabilities = ProviderCapabilities( + provider=Provider.FACEBOOK, connection_strategy=ConnectionStrategy.OAUTH, + implementation_status="registered", + account_types=["facebook_page"], + # The Graph endpoint and normalization are implemented, but these + # are opt-in scopes. A standard connection is never escalated. + analytics=True, + analytics_required_scopes=["pages_read_engagement", "read_insights"], + ) + + async def close(self) -> None: + await self._graph.close() + + async def get_metrics( + self, token: dict[str, Any], external_id: str + ) -> dict[str, Any]: + """Read actual Page-post data using the official Graph API. + + The compact fields request avoids deprecated aggregate guessing. A + missing field stays absent in the normalized response; zero is never + inferred from an empty Meta response. + """ + + payload = await self._graph.get( + external_id, + _token_mapping(token), + params={ + "fields": ( + "created_time," + "insights.metric(post_impressions,post_engaged_users," + "post_clicks,post_video_views)," + "reactions.limit(0).summary(true)," + "comments.limit(0).summary(true),shares" + ) + }, + ) + insight_payload = payload.get("insights") + insights = insight_values(insight_payload) if isinstance(insight_payload, dict) else {} + normalized: dict[str, Any] = { + "status": "available", + "raw_metrics": { + "post_insights": insight_payload if isinstance(insight_payload, dict) else {}, + "reactions": payload.get("reactions"), + "comments": payload.get("comments"), + "shares": payload.get("shares"), + }, + } + _set_number(normalized, "impressions", insights.get("post_impressions")) + _set_number(normalized, "views", insights.get("post_video_views")) + _set_number(normalized, "engaged_users", insights.get("post_engaged_users")) + _set_number(normalized, "clicks", insights.get("post_clicks")) + _set_number(normalized, "likes", _summary_count(payload.get("reactions"))) + _set_number(normalized, "comments", _summary_count(payload.get("comments"))) + shares = payload.get("shares") + if isinstance(shares, dict): + _set_number(normalized, "shares", shares.get("count")) + if isinstance(payload.get("created_time"), str): + normalized["published_at"] = payload["created_time"] + if not any( + key in normalized + for key in ("impressions", "views", "engaged_users", "clicks", "likes", "comments", "shares") + ): + return { + "status": "unavailable", + "reason": "META_METRICS_NOT_AVAILABLE", + "raw_metrics": normalized["raw_metrics"], + } + return normalized + + +def _token_mapping(token: dict[str, Any]) -> Mapping[str, object]: + return token + + +def _summary_count(value: object) -> int | float | None: + if not isinstance(value, dict): + return None + summary = value.get("summary") + if not isinstance(summary, dict): + return None + count = summary.get("total_count") + return count if isinstance(count, (int, float)) and not isinstance(count, bool) else None + + +def _set_number(target: dict[str, Any], key: str, value: object) -> None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + target[key] = value diff --git a/app/social/providers/instagram.py b/app/social/providers/instagram.py new file mode 100644 index 0000000000000000000000000000000000000000..ada368824bd036c5e0dd2b706bf3626445c299e0 --- /dev/null +++ b/app/social/providers/instagram.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx + +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.providers.meta_graph import MetaGraphClient, insight_values +from app.social.providers.oauth import OAuthFoundationAdapter + + +class InstagramProvider(OAuthFoundationAdapter): + + def __init__( + self, settings: Settings, *, http_client: httpx.AsyncClient | None = None + ) -> None: + self.client_id = settings.resolved_meta_app_id + self.authorization_endpoint = ( + f"https://www.facebook.com/{settings.meta_graph_api_version}/dialog/oauth" + ) + self._graph = MetaGraphClient(settings, http_client=http_client) + self.capabilities = ProviderCapabilities( + provider=Provider.INSTAGRAM, connection_strategy=ConnectionStrategy.OAUTH, + implementation_status="registered", + account_types=["instagram_professional_account"], + analytics=True, + analytics_required_scopes=["instagram_basic", "instagram_manage_insights"], + ) + + async def close(self) -> None: + await self._graph.close() + + async def get_metrics( + self, token: dict[str, Any], external_id: str + ) -> dict[str, Any]: + """Fetch Instagram professional-media insights by product type. + + Meta does not support one universal metrics set. Querying the media + object first prevents an unsupported-metric request for albums and + ensures Reels use their documented metric family. + """ + + media = await self._graph.get( + external_id, + _token_mapping(token), + params={"fields": "media_product_type,media_type,timestamp,permalink"}, + ) + product_type = str(media.get("media_product_type", "")).upper() + media_type = str(media.get("media_type", "")).upper() + metrics = _metrics_for(product_type, media_type) + if not metrics: + return { + "status": "unavailable", + "reason": "INSTAGRAM_MEDIA_TYPE_ANALYTICS_UNAVAILABLE", + "raw_metrics": {"media": media}, + } + payload = await self._graph.get( + f"{external_id}/insights", + _token_mapping(token), + params={"metric": ",".join(metrics)}, + ) + values = insight_values(payload) + if not values: + # Meta documents an empty data set for unavailable data. It is not + # a numeric zero and must not be stored as one. + return { + "status": "unavailable", + "reason": "META_METRICS_NOT_AVAILABLE", + "raw_metrics": {"media": media, "insights": payload}, + } + normalized: dict[str, Any] = { + "status": "available", + "raw_metrics": {"media": media, "insights": payload}, + } + for key in ("views", "reach", "likes", "comments", "shares", "saved"): + _set_number(normalized, key, values.get(key)) + if isinstance(media.get("timestamp"), str): + normalized["published_at"] = media["timestamp"] + if isinstance(media.get("permalink"), str): + normalized["url"] = media["permalink"] + return normalized + + +def _metrics_for(product_type: str, media_type: str) -> list[str]: + if product_type == "REELS": + return ["views", "reach", "likes", "comments", "shares", "saved"] + if product_type == "FEED": + # `views` is applicable only to playable feed video. Image posts use + # the common engagement/reach metrics. + metrics = ["reach", "likes", "comments", "shares", "saved"] + if media_type == "VIDEO": + metrics.insert(0, "views") + return metrics + # Meta documents no per-item insights for an album. Do not issue a request + # that would either fail or tempt callers to fabricate an aggregate. + return [] + + +def _token_mapping(token: dict[str, Any]) -> Mapping[str, object]: + return token + + +def _set_number(target: dict[str, Any], key: str, value: object) -> None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + target[key] = value diff --git a/app/social/providers/linkedin.py b/app/social/providers/linkedin.py new file mode 100644 index 0000000000000000000000000000000000000000..775d3390f1451a9fb0841945e1a8476462b1f827 --- /dev/null +++ b/app/social/providers/linkedin.py @@ -0,0 +1,15 @@ +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.providers.oauth import OAuthFoundationAdapter + + +class LinkedInProvider(OAuthFoundationAdapter): + authorization_endpoint = "https://www.linkedin.com/oauth/v2/authorization" + + def __init__(self, settings: Settings) -> None: + self.client_id = settings.linkedin_client_id + self.capabilities = ProviderCapabilities( + provider=Provider.LINKEDIN, connection_strategy=ConnectionStrategy.OAUTH, + implementation_status="registered", account_types=["member", "organization"], + ) diff --git a/app/social/providers/meta.py b/app/social/providers/meta.py new file mode 100644 index 0000000000000000000000000000000000000000..359454212a02433ff9c0db80305bbc4957ca6ce0 --- /dev/null +++ b/app/social/providers/meta.py @@ -0,0 +1,3 @@ +"""Shared Meta family marker; Facebook Pages and Instagram accounts stay distinct.""" + +META_PROVIDER_FAMILY = frozenset({"facebook", "instagram"}) diff --git a/app/social/providers/meta_graph.py b/app/social/providers/meta_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..d4fcdcdee6c267ef66999d89a247306c3cc8fc64 --- /dev/null +++ b/app/social/providers/meta_graph.py @@ -0,0 +1,115 @@ +"""Official Meta Graph API helpers shared by Meta analytics adapters. + +Access tokens are sent as Authorization bearer credentials instead of URL +parameters. This prevents accidental credential capture by URL logging, +proxies, exceptions, or observability tooling. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import httpx + +from app.core.config import Settings +from app.social.domain.errors import ( + SocialPermissionDeniedError, + SocialProviderUnavailableError, + SocialRateLimitedError, + SocialReauthRequiredError, +) + +_GRAPH_API_ROOT = "https://graph.facebook.com" +_AUTHENTICATION_ERROR_CODES = frozenset({102, 190}) +_PERMISSION_ERROR_CODES = frozenset({10, 200, 299}) +_RATE_LIMIT_ERROR_CODES = frozenset({4, 17, 32, 613}) + + +class MetaGraphClient: + """Minimal Graph transport with normalized, non-sensitive errors.""" + + def __init__( + self, settings: Settings, *, http_client: httpx.AsyncClient | None = None + ) -> None: + self._version = settings.meta_graph_api_version + self._client = http_client or httpx.AsyncClient( + timeout=httpx.Timeout(30.0), follow_redirects=False + ) + self._owns_client = http_client is None + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def get( + self, object_path: str, token: Mapping[str, object], *, params: Mapping[str, str] + ) -> dict[str, Any]: + access_token = token.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise SocialReauthRequiredError("The Meta account requires reauthorization.") + try: + response = await self._client.get( + f"{_GRAPH_API_ROOT}/{self._version}/{object_path.lstrip('/')}", + params=dict(params), + headers={"Authorization": f"Bearer {access_token}"}, + ) + except httpx.TransportError as exc: + raise SocialProviderUnavailableError( + "Meta analytics is temporarily unavailable." + ) from exc + if response.is_success: + try: + payload = response.json() + except ValueError as exc: + raise SocialProviderUnavailableError( + "Meta returned an invalid analytics response." + ) from exc + if isinstance(payload, dict): + return payload + raise SocialProviderUnavailableError("Meta returned an invalid analytics response.") + self._raise_graph_error(response) + raise AssertionError("Meta graph error mapping must raise") + + @staticmethod + def _raise_graph_error(response: httpx.Response) -> None: + code: int | None = None + try: + payload = response.json() + error = payload.get("error", {}) if isinstance(payload, dict) else {} + raw_code = error.get("code") if isinstance(error, dict) else None + code = int(raw_code) if raw_code is not None else None + except (TypeError, ValueError): + pass + if response.status_code == 401 or code in _AUTHENTICATION_ERROR_CODES: + raise SocialReauthRequiredError("The Meta account requires reauthorization.") + if response.status_code == 403 or code in _PERMISSION_ERROR_CODES: + raise SocialPermissionDeniedError("Meta analytics permission was denied.") + if response.status_code == 429 or code in _RATE_LIMIT_ERROR_CODES: + raise SocialRateLimitedError("Meta analytics rate limit reached.") + if response.status_code >= 500 or code == 1: + raise SocialProviderUnavailableError("Meta analytics is temporarily unavailable.") + raise SocialProviderUnavailableError("Meta analytics request was rejected.") + + +def insight_values(payload: Mapping[str, Any]) -> dict[str, int | float | None]: + """Extract only scalar values Meta actually returned for each metric.""" + + metrics: dict[str, int | float | None] = {} + raw_data = payload.get("data") + if not isinstance(raw_data, list): + return metrics + for item in raw_data: + if not isinstance(item, dict) or not isinstance(item.get("name"), str): + continue + value: Any = None + values = item.get("values") + if isinstance(values, list) and values and isinstance(values[-1], dict): + value = values[-1].get("value") + elif isinstance(item.get("total_value"), dict): + value = item["total_value"].get("value") + if isinstance(value, bool): + continue + if isinstance(value, (int, float)): + metrics[item["name"]] = value + return metrics diff --git a/app/social/providers/oauth.py b/app/social/providers/oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..b9728d285b0c4fb755b310e0a1b247cfefbb33b2 --- /dev/null +++ b/app/social/providers/oauth.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from app.social.domain.errors import SocialProviderUnavailableError +from app.social.providers.base import SocialProviderAdapter + + +class OAuthFoundationAdapter(SocialProviderAdapter): + authorization_endpoint: str + client_id: str + + async def get_authorization_url( + self, + *, + state: str, + redirect_uri: str, + code_challenge: str | None = None, + additional_scopes: list[str] | None = None, + ) -> str: + if not self.client_id: + raise SocialProviderUnavailableError( + f"{self.provider} OAuth credentials are not configured." + ) + params = { + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + "scope": " ".join( + dict.fromkeys( + [*self.capabilities.required_scopes, *(additional_scopes or [])] + ) + ), + } + if code_challenge: + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + return f"{self.authorization_endpoint}?{urlencode(params)}" diff --git a/app/social/providers/registry.py b/app/social/providers/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..d472f9e6135afe4cb4d9d25d2c7e3d1c4e067fd9 --- /dev/null +++ b/app/social/providers/registry.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from app.core.config import Settings +from app.social.domain.enums import Provider +from app.social.domain.errors import SocialProviderUnavailableError +from app.social.providers.base import SocialProviderAdapter +from app.social.providers.facebook import FacebookProvider +from app.social.providers.instagram import InstagramProvider +from app.social.providers.linkedin import LinkedInProvider +from app.social.providers.telegram import TelegramProvider +from app.social.providers.tiktok import TikTokProvider +from app.social.providers.whatsapp import WhatsAppProvider +from app.social.providers.x import XProvider +from app.social.providers.youtube import YouTubeProvider + + +class ProviderRegistry: + def __init__(self, providers: list[SocialProviderAdapter]) -> None: + self._providers = {provider.provider: provider for provider in providers} + + def list(self) -> list[SocialProviderAdapter]: + return [self._providers[key] for key in sorted(self._providers)] + + def get(self, provider: str | Provider) -> SocialProviderAdapter: + key = provider.value if isinstance(provider, Provider) else provider.strip().lower() + try: + return self._providers[key] + except KeyError as exc: + raise SocialProviderUnavailableError(f"Unknown social provider '{key}'.") from exc + + async def close(self) -> None: + for provider in self._providers.values(): + close = getattr(provider, "close", None) + if close is not None: + await close() + + +def build_provider_registry(settings: Settings) -> ProviderRegistry: + return ProviderRegistry([ + YouTubeProvider(settings), FacebookProvider(settings), InstagramProvider(settings), + TikTokProvider(settings), XProvider(settings), LinkedInProvider(settings), + TelegramProvider(settings), WhatsAppProvider(settings), + ]) diff --git a/app/social/providers/telegram.py b/app/social/providers/telegram.py new file mode 100644 index 0000000000000000000000000000000000000000..97fcd5505314381077374445f0edd87d5b9e78eb --- /dev/null +++ b/app/social/providers/telegram.py @@ -0,0 +1,13 @@ +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.providers.base import SocialProviderAdapter + + +class TelegramProvider(SocialProviderAdapter): + def __init__(self, settings: Settings) -> None: + self.capabilities = ProviderCapabilities( + provider=Provider.TELEGRAM, connection_strategy=ConnectionStrategy.TOKEN_BOT, + account_types=["bot", "channel"], + implementation_status="registered", + ) diff --git a/app/social/providers/tiktok.py b/app/social/providers/tiktok.py new file mode 100644 index 0000000000000000000000000000000000000000..d9c33a73dd6cbf59ffb165f1d49f77392cefb647 --- /dev/null +++ b/app/social/providers/tiktok.py @@ -0,0 +1,1131 @@ +"""Official TikTok Login Kit and Content Posting Direct Post adapter.""" + +from __future__ import annotations + +import asyncio +import os +import re +from pathlib import Path +from typing import Any +from urllib.parse import urlencode, urlparse + +import aiofiles +import httpx + +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.domain.errors import ( + SocialCapabilityUnsupportedError, + SocialMediaInvalidError, + SocialPermissionDeniedError, + SocialProviderUnavailableError, + SocialPublishFailedError, + SocialRateLimitedError, + SocialReauthRequiredError, +) +from app.social.providers.oauth import OAuthFoundationAdapter +from app.social.security import public_provider_data + +_TIKTOK_OPEN_API = "https://open.tiktokapis.com" +_TOKEN_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/oauth/token/" +_REVOKE_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/oauth/revoke/" +_USER_INFO_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/user/info/" +_CREATOR_INFO_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/post/publish/creator_info/query/" +_DIRECT_POST_INIT_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/post/publish/video/init/" +_PUBLISH_STATUS_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/post/publish/status/fetch/" +_VIDEO_QUERY_ENDPOINT = f"{_TIKTOK_OPEN_API}/v2/video/query/" +_USER_FIELDS = "open_id,union_id,avatar_url,display_name" +_VIDEO_ANALYTICS_FIELDS = ( + "id,create_time,share_url,title,video_description,duration,height,width," + "like_count,comment_count,share_count,view_count" +) +_RETRYABLE = frozenset({429, 500, 502, 503, 504}) +_MIN_CHUNK = 5_000_000 +_MAX_CHUNK = 64_000_000 +_MAX_FINAL_CHUNK = 128_000_000 +_MAX_VIDEO_SIZE = 4_000_000_000 +_MAX_VIDEO_DURATION = 600.0 +_CONTENT_RANGE = re.compile(r"bytes\s+0-(\d+)/(\d+)", re.IGNORECASE) +_MEDIA_FAILURES = frozenset( + { + "file_format_check_failed", + "duration_check_failed", + "frame_rate_check_failed", + "picture_size_check_failed", + } +) + + +class TikTokProvider(OAuthFoundationAdapter): + """TikTok Web Login Kit plus audited Direct Post video publishing.""" + + authorization_endpoint = "https://www.tiktok.com/v2/auth/authorize/" + pkce_supported = False + + def __init__( + self, settings: Settings, *, http_client: httpx.AsyncClient | None = None + ) -> None: + self.settings = settings + self.reconciliation_poll_seconds = settings.tiktok_processing_poll_seconds + self.client_id = settings.tiktok_client_key + self._client_secret = ( + settings.tiktok_client_secret.get_secret_value() + if settings.tiktok_client_secret + else "" + ) + self.redirect_uri = settings.tiktok_redirect_uri.strip() + self.configuration_ready = bool( + self.client_id and self._client_secret and self.redirect_uri + ) + self._client = http_client or httpx.AsyncClient( + timeout=httpx.Timeout(settings.tiktok_request_timeout_seconds), + follow_redirects=False, + ) + self._owns_client = http_client is None + # Capability advertisement is fail-closed until both the operator gate + # and complete backend credentials/callback configuration are present. + direct_post = settings.tiktok_direct_post_enabled and self.configuration_ready + self.capabilities = ProviderCapabilities( + provider=Provider.TIKTOK, + connection_strategy=ConnectionStrategy.OAUTH, + implementation_status="implemented", + account_types=["creator"], + required_scopes=["user.info.basic"], + optional_scopes=["video.publish", "video.list"], + publishing_required_scopes=["video.publish"] if direct_post else [], + # TikTok Display API video/query is independently authorized. The + # normal connection and publishing flows never request video.list. + analytics=self.configuration_ready, + analytics_required_scopes=["video.list"] if self.configuration_ready else [], + video=direct_post, + video_upload=direct_post, + video_status=direct_post, + direct_publish=direct_post, + # MediaRouter dispatches scheduled jobs at the canonical UTC time. + # TikTok currently exposes no native scheduling parameter. + scheduled_publish=direct_post, + native_scheduling=False, + delete_post=False, + publish_metadata_schema=( + { + "namespace": "tiktok", + "media_types": ["video"], + "fields": [ + {"name": "title", "label": "Caption", "type": "text", "required": False, "max_length": 2200}, + {"name": "privacy_level", "label": "Privacy", "type": "select", "required": True, "options_source": "privacy_level_options"}, + {"name": "disable_comment", "label": "Disable comments", "type": "boolean", "required": False}, + {"name": "disable_duet", "label": "Disable Duet", "type": "boolean", "required": False}, + {"name": "disable_stitch", "label": "Disable Stitch", "type": "boolean", "required": False}, + {"name": "brand_content_toggle", "label": "Paid partnership", "type": "boolean", "required": True}, + {"name": "brand_organic_toggle", "label": "Promotes own brand", "type": "boolean", "required": True}, + {"name": "is_aigc", "label": "AI-generated content", "type": "boolean", "required": True}, + {"name": "music_usage_confirmed", "label": "I agree to TikTok's Music Usage Confirmation", "type": "confirmation", "required": True}, + ], + } + if direct_post + else {} + ), + ) + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def get_authorization_url( + self, + *, + state: str, + redirect_uri: str, + code_challenge: str | None = None, + additional_scopes: list[str] | None = None, + ) -> str: + if not self.client_id: + raise SocialProviderUnavailableError( + "TikTok OAuth credentials are not configured." + ) + scopes = list( + dict.fromkeys( + [*self.capabilities.required_scopes, *(additional_scopes or [])] + ) + ) + params = { + "client_key": self.client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": ",".join(scopes), + "state": state, + } + # Login Kit Web is a confidential-client flow and its current official + # contract does not define the mobile/desktop code_verifier fields. + del code_challenge + return f"{self.authorization_endpoint}?{urlencode(params)}" + + async def exchange_code( + self, *, code: str, redirect_uri: str, code_verifier: str | None = None + ) -> dict[str, Any]: + if not self.client_id or not self._client_secret: + raise SocialProviderUnavailableError( + "TikTok OAuth credentials are not configured." + ) + del code_verifier + return await self._token_request( + { + "client_key": self.client_id, + "client_secret": self._client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": redirect_uri, + } + ) + + async def refresh_token(self, token: dict[str, Any]) -> dict[str, Any]: + refresh_token = token.get("refresh_token") + if not isinstance(refresh_token, str) or not refresh_token: + raise SocialReauthRequiredError( + "The TikTok account requires reauthorization." + ) + if not self.client_id or not self._client_secret: + raise SocialProviderUnavailableError( + "TikTok OAuth credentials are not configured." + ) + return await self._token_request( + { + "client_key": self.client_id, + "client_secret": self._client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + ) + + async def revoke_token(self, token: dict[str, Any]) -> None: + access_token = token.get("access_token") + if not isinstance(access_token, str) or not access_token: + return + if not self.client_id or not self._client_secret: + raise SocialProviderUnavailableError( + "TikTok OAuth credentials are not configured." + ) + try: + response = await self._client.post( + _REVOKE_ENDPOINT, + data={ + "client_key": self.client_id, + "client_secret": self._client_secret, + "token": access_token, + }, + ) + except httpx.TransportError as exc: + raise SocialProviderUnavailableError( + "TikTok token revocation is temporarily unavailable." + ) from exc + if response.is_success: + return + payload = self._response_payload(response, operation="token revocation") + self._raise_tiktok_error(response, payload, operation="token revocation") + + async def get_account(self, token: dict[str, Any]) -> dict[str, Any]: + payload = await self._authorized_get( + _USER_INFO_ENDPOINT, + token, + params={"fields": _USER_FIELDS}, + operation="profile discovery", + ) + data = payload.get("data") + user = data.get("user") if isinstance(data, dict) else None + if ( + not isinstance(user, dict) + or not isinstance(user.get("open_id"), str) + or not user["open_id"] + ): + raise SocialReauthRequiredError( + "TikTok did not return an authenticated account identity." + ) + open_id = user["open_id"] + metadata = { + "tiktok_open_id": open_id, + "tiktok_union_id": ( + user.get("union_id") + if isinstance(user.get("union_id"), str) + else None + ), + } + return { + "external_account_id": open_id, + "account_type": "creator", + "username": None, + "display_name": ( + user.get("display_name") + if isinstance(user.get("display_name"), str) + else None + ), + "avatar_url": ( + user.get("avatar_url") + if isinstance(user.get("avatar_url"), str) + else None + ), + "metadata": { + key: value for key, value in metadata.items() if value is not None + }, + } + + async def get_publish_options(self, token: dict[str, Any]) -> dict[str, Any]: + self._ensure_direct_post_enabled() + payload = await self._authorized_post( + _CREATOR_INFO_ENDPOINT, + token, + json={}, + operation="creator information", + ) + data = payload.get("data") + if not isinstance(data, dict): + raise SocialProviderUnavailableError( + "TikTok returned invalid creator publishing options." + ) + privacy = data.get("privacy_level_options") + duration = data.get("max_video_post_duration_sec") + if not isinstance(privacy, list) or not all( + isinstance(item, str) and item for item in privacy + ): + raise SocialProviderUnavailableError( + "TikTok returned invalid creator privacy options." + ) + if ( + not isinstance(duration, (int, float)) + or isinstance(duration, bool) + or duration <= 0 + ): + raise SocialProviderUnavailableError( + "TikTok returned an invalid creator duration limit." + ) + disabled_options: dict[str, bool] = {} + for name in ("comment_disabled", "duet_disabled", "stitch_disabled"): + value = data.get(name) + if not isinstance(value, bool): + raise SocialProviderUnavailableError( + "TikTok returned invalid creator interaction options." + ) + disabled_options[name] = value + return { + "privacy_level_options": privacy, + **disabled_options, + "max_video_post_duration_sec": min(float(duration), _MAX_VIDEO_DURATION), + "creator_username": ( + data.get("creator_username") + if isinstance(data.get("creator_username"), str) + else None + ), + "creator_nickname": ( + data.get("creator_nickname") + if isinstance(data.get("creator_nickname"), str) + else None + ), + } + + async def validate_media(self, media: dict[str, Any]) -> None: + self._ensure_direct_post_enabled() + path = media.get("path") + size = media.get("file_size") + mime_type = str(media.get("mime_type") or "").lower() + probe = media.get("probe") if isinstance(media.get("probe"), dict) else {} + if ( + not isinstance(path, Path) + or not path.is_file() + or not os.access(path, os.R_OK) + ): + raise SocialMediaInvalidError("TikTok media asset is not readable.") + try: + actual_size = path.stat().st_size + except OSError as exc: + raise SocialMediaInvalidError( + "TikTok media asset is not readable." + ) from exc + if actual_size != size: + raise SocialMediaInvalidError( + "TikTok media asset size changed after validation." + ) + maximum = min(self.settings.max_upload_size, _MAX_VIDEO_SIZE) + if not isinstance(size, int) or size <= 0 or size > maximum: + raise SocialMediaInvalidError( + "TikTok video size exceeds the configured or official limit." + ) + if mime_type not in {"video/mp4", "video/quicktime", "video/webm"}: + raise SocialMediaInvalidError( + "TikTok supports registered MP4, MOV, or WebM video variants." + ) + container = str(probe.get("container") or "").lower() + if not any(value in container for value in ("mp4", "quicktime", "webm")): + raise SocialMediaInvalidError( + "TikTok video container is unsupported; create a compatible MediaRouter variant." + ) + streams = probe.get("video_streams") + if ( + not isinstance(streams, list) + or not streams + or not isinstance(streams[0], dict) + ): + raise SocialMediaInvalidError("TikTok media must contain a video stream.") + codec = str(streams[0].get("codec") or "").lower() + if codec not in {"h264", "hevc", "h265", "vp8", "vp9"}: + raise SocialMediaInvalidError( + "TikTok video codec is unsupported; create a compatible MediaRouter variant." + ) + duration = probe.get("duration") + if ( + not isinstance(duration, (int, float)) + or isinstance(duration, bool) + or duration <= 0 + or duration > _MAX_VIDEO_DURATION + ): + raise SocialMediaInvalidError( + "TikTok video duration must be positive and no longer than 10 minutes." + ) + fps = probe.get("fps") + if ( + not isinstance(fps, (int, float)) + or isinstance(fps, bool) + or fps < 23 + or fps > 60 + ): + raise SocialMediaInvalidError( + "TikTok video frame rate must be between 23 and 60 FPS." + ) + resolution = ( + probe.get("resolution") + if isinstance(probe.get("resolution"), dict) + else {} + ) + width, height = resolution.get("width"), resolution.get("height") + if ( + not isinstance(width, int) + or not isinstance(height, int) + or not 360 <= width <= 4096 + or not 360 <= height <= 4096 + ): + raise SocialMediaInvalidError( + "TikTok video width and height must each be between 360 and 4096 pixels." + ) + aspect_ratio = width / height + if not 0 < aspect_ratio < float("inf"): + raise SocialMediaInvalidError("TikTok video aspect ratio is invalid.") + audio_streams = probe.get("audio_streams") + if audio_streams is not None and not isinstance(audio_streams, list): + raise SocialMediaInvalidError("TikTok audio stream metadata is invalid.") + # TikTok does not require an audio track. For present audio, accept the + # codecs produced by MediaRouter's compatible MP4/WebM templates. + for stream in audio_streams or []: + if ( + not isinstance(stream, dict) + or str(stream.get("codec") or "").lower() + not in {"aac", "mp3", "opus", "vorbis"} + ): + raise SocialMediaInvalidError( + "TikTok audio codec is unsupported; create a compatible MediaRouter variant." + ) + + async def upload_media( + self, token: dict[str, Any], media: dict[str, Any] + ) -> dict[str, Any]: + await self.validate_media(media) + path = media["path"] + assert isinstance(path, Path) + total = int(media["file_size"]) + post_info = media.get("tiktok_post_info") + if not isinstance(post_info, dict): + raise SocialPublishFailedError( + "Typed TikTok Direct Post metadata is required." + ) + creator = await self.get_publish_options(token) + self._validate_post_info(post_info, creator, media) + persist = media.get("persist_provider_state") + if persist is not None and not callable(persist): + raise SocialPublishFailedError( + "TikTok provider-state persistence is invalid." + ) + heartbeat = media.get("heartbeat") + if heartbeat is not None and not callable(heartbeat): + raise SocialPublishFailedError("TikTok upload heartbeat is invalid.") + state = ( + dict(media.get("provider_state")) + if isinstance(media.get("provider_state"), dict) + else {} + ) + publish_id = state.get("tiktok_publish_id") + upload_url = state.get("tiktok_upload_url") + chunk_size = state.get("tiktok_chunk_size") + chunk_count = state.get("tiktok_chunk_count") + if not isinstance(publish_id, str) or not publish_id: + if state.get("tiktok_init_started"): + # TikTok has no client idempotency key or lookup-by-client-key + # endpoint. Never repeat an init whose accepted outcome could + # not be durably identified. + raise SocialPublishFailedError( + "TikTok initialization outcome is unavailable; duplicate publishing was prevented." + ) + if persist: + await persist( + { + "tiktok_init_started": True, + "tiktok_video_size": total, + } + ) + chunk_size, chunk_count = self._chunk_plan(total) + payload = await self._authorized_post( + _DIRECT_POST_INIT_ENDPOINT, + token, + json={ + "post_info": post_info, + "source_info": { + "source": "FILE_UPLOAD", + "video_size": total, + "chunk_size": chunk_size, + "total_chunk_count": chunk_count, + }, + }, + operation="Direct Post initialization", + ) + data = payload.get("data") + if not isinstance(data, dict): + raise SocialProviderUnavailableError( + "TikTok did not return a Direct Post upload session." + ) + publish_id = data.get("publish_id") + upload_url = data.get("upload_url") + if not isinstance(publish_id, str) or not publish_id: + raise SocialProviderUnavailableError( + "TikTok did not return a Direct Post publish ID." + ) + if not isinstance(upload_url, str) or not self._is_upload_url(upload_url): + raise SocialProviderUnavailableError( + "TikTok did not return a trusted upload URL." + ) + state = { + "tiktok_init_started": True, + "tiktok_publish_id": publish_id, + "tiktok_upload_url": upload_url, + "tiktok_video_size": total, + "tiktok_chunk_size": chunk_size, + "tiktok_chunk_count": chunk_count, + "tiktok_uploaded_bytes": 0, + } + if persist: + await persist(state) + else: + if state.get("tiktok_video_size") != total: + raise SocialMediaInvalidError( + "TikTok retry media does not match the initialized upload." + ) + status = await self.get_publish_status(token, publish_id) + if status["status"] == "published": + return {"id": publish_id, "metadata": status.get("metadata", {})} + if status["status"] == "failed": + raise self.publish_failure(status) + uploaded = status.get("metadata", {}).get("uploaded_bytes") + if isinstance(uploaded, int) and uploaded >= total: + return {"id": publish_id} + if isinstance(uploaded, int) and uploaded >= 0: + state["tiktok_uploaded_bytes"] = uploaded + + if not isinstance(upload_url, str) or not self._is_upload_url(upload_url): + raise SocialProviderUnavailableError( + "TikTok upload session cannot be safely resumed." + ) + if not isinstance(chunk_size, int) or not isinstance(chunk_count, int): + raise SocialProviderUnavailableError( + "TikTok upload session has invalid chunk metadata." + ) + position = state.get("tiktok_uploaded_bytes", 0) + if not isinstance(position, int) or position < 0 or position > total: + raise SocialProviderUnavailableError( + "TikTok returned an invalid upload position." + ) + if position < total and position % chunk_size != 0: + raise SocialProviderUnavailableError( + "TikTok upload position cannot be safely resumed." + ) + try: + await self._upload_chunks( + token=token, + publish_id=publish_id, + upload_url=upload_url, + path=path, + mime_type=str(media["mime_type"]), + total=total, + chunk_size=chunk_size, + chunk_count=chunk_count, + position=position, + persist=persist, + state=state, + heartbeat=heartbeat, + ) + except OSError as exc: + raise SocialMediaInvalidError( + "TikTok media asset could not be read during upload." + ) from exc + return {"id": publish_id} + + async def publish( + self, token: dict[str, Any], payload: dict[str, Any] + ) -> dict[str, Any]: + del token + upload = payload.get("upload") + if not isinstance(upload, dict) or not isinstance(upload.get("id"), str): + raise SocialPublishFailedError( + "TikTok upload did not return a publish ID." + ) + # Direct Post is initiated by /video/init and starts processing after + # the final upload chunk. There is no second publish endpoint. + return dict(upload) + + async def get_publish_status( + self, token: dict[str, Any], external_id: str + ) -> dict[str, Any]: + self._ensure_direct_post_enabled() + response = await self._authorized_request( + "POST", + _PUBLISH_STATUS_ENDPOINT, + token, + json={"publish_id": external_id}, + ) + payload = self._response_payload(response, operation="publish status") + error = payload.get("error") + code = str(error.get("code", "")).lower() if isinstance(error, dict) else "" + if response.status_code == 400 and code == "invalid_publish_id": + return { + "id": external_id, + "status": "unavailable", + "metadata": {"provider_status": "INVALID_PUBLISH_ID"}, + } + self._raise_tiktok_error(response, payload, operation="publish status") + data = payload.get("data") + if not isinstance(data, dict): + raise SocialProviderUnavailableError( + "TikTok returned an invalid publish status." + ) + provider_status = str(data.get("status") or "").upper() + if provider_status == "PUBLISH_COMPLETE": + normalized = "published" + elif provider_status in { + "PROCESSING_UPLOAD", + "PROCESSING_DOWNLOAD", + "SEND_TO_USER_INBOX", + }: + normalized = "processing" + elif provider_status == "FAILED": + normalized = "failed" + else: + normalized = "unavailable" + public_ids = data.get("publicaly_available_post_id") + if not isinstance(public_ids, list): + # Accept the corrected spelling defensively if TikTok fixes the + # long-standing response-field typo without a version bump. + public_ids = data.get("publicly_available_post_id") + metadata: dict[str, object] = { + "provider_status": provider_status or None, + "fail_reason": ( + data.get("fail_reason") + if isinstance(data.get("fail_reason"), str) + else None + ), + "uploaded_bytes": ( + data.get("uploaded_bytes") + if isinstance(data.get("uploaded_bytes"), int) + else None + ), + "public_post_ids": ( + [str(item) for item in public_ids] + if isinstance(public_ids, list) + else [] + ), + } + return { + "id": external_id, + "status": normalized, + "metadata": { + key: value for key, value in metadata.items() if value is not None + }, + } + + async def get_metrics( + self, token: dict[str, Any], external_id: str + ) -> dict[str, Any]: + """Return only TikTok video metrics documented by Display API v2. + + `external_id` must be the publicly available video ID returned by the + Content Posting status endpoint, not the private Direct Post + `publish_id`. AnalyticsService resolves that identity from safe target + metadata before calling this adapter. + """ + + if not self.configuration_ready: + raise SocialCapabilityUnsupportedError( + "TikTok analytics are unavailable until the provider is configured." + ) + if not external_id or len(external_id) > 255: + raise SocialPublishFailedError("TikTok analytics video ID is invalid.") + response = await self._authorized_request( + "POST", + _VIDEO_QUERY_ENDPOINT, + token, + params={"fields": _VIDEO_ANALYTICS_FIELDS}, + json={"filters": {"video_ids": [external_id]}}, + ) + payload = self._response_payload(response, operation="video analytics") + self._raise_tiktok_error(response, payload, operation="video analytics") + data = payload.get("data") + videos = data.get("videos") if isinstance(data, dict) else None + if not isinstance(videos, list): + raise SocialProviderUnavailableError( + "TikTok returned an invalid video analytics response." + ) + video = next( + ( + item + for item in videos + if isinstance(item, dict) and str(item.get("id") or "") == external_id + ), + None, + ) + if video is None: + return { + "status": "unavailable", + "reason": "TIKTOK_VIDEO_NOT_AVAILABLE_TO_AUTHORIZED_USER", + } + + result: dict[str, Any] = { + "status": "available", + "raw_metrics": public_provider_data(dict(video)), + } + for provider_name, normalized_name in ( + ("view_count", "views"), + ("like_count", "likes"), + ("comment_count", "comments"), + ("share_count", "shares"), + ): + value = video.get(provider_name) + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + result[normalized_name] = value + created = video.get("create_time") + if isinstance(created, int) and not isinstance(created, bool) and created >= 0: + result["published_at"] = created + if isinstance(video.get("share_url"), str): + result["url"] = video["share_url"] + return result + + def publish_failure(self, result: dict[str, Any]) -> Exception: + metadata = result.get("metadata") if isinstance(result.get("metadata"), dict) else {} + reason = str(metadata.get("fail_reason") or "").lower() + if reason in _MEDIA_FAILURES: + return SocialMediaInvalidError( + "TikTok rejected media that does not meet its current restrictions." + ) + if reason == "auth_removed": + return SocialReauthRequiredError( + "The TikTok account requires reauthorization." + ) + if reason in {"internal", "video_pull_failed", "photo_pull_failed"}: + return SocialProviderUnavailableError( + "TikTok publishing is temporarily unavailable." + ) + if result.get("status") == "unavailable": + return SocialProviderUnavailableError( + "TikTok publish status is unavailable." + ) + return SocialPublishFailedError( + "TikTok rejected the publishing request." + ) + + async def _upload_chunks( + self, + *, + token: dict[str, Any], + publish_id: str, + upload_url: str, + path: Path, + mime_type: str, + total: int, + chunk_size: int, + chunk_count: int, + position: int, + persist: Any, + state: dict[str, object], + heartbeat: Any, + ) -> None: + async with aiofiles.open(path, "rb") as source: + await source.seek(position) + while position < total: + if heartbeat: + await heartbeat() + index = position // chunk_size + if index >= chunk_count: + raise SocialProviderUnavailableError( + "TikTok upload exceeded its initialized chunk count." + ) + length = total - position if index == chunk_count - 1 else chunk_size + if length <= 0 or length > _MAX_FINAL_CHUNK: + raise SocialProviderUnavailableError( + "TikTok upload chunk plan is invalid." + ) + chunk = await source.read(length) + if len(chunk) != length: + raise SocialMediaInvalidError( + "TikTok upload file ended before its registered size." + ) + position = await self._put_chunk( + token=token, + publish_id=publish_id, + upload_url=upload_url, + chunk=chunk, + start=position, + total=total, + mime_type=mime_type, + ) + await source.seek(position) + state["tiktok_uploaded_bytes"] = position + if persist: + await persist(state) + + async def _put_chunk( + self, + *, + token: dict[str, Any], + publish_id: str, + upload_url: str, + chunk: bytes, + start: int, + total: int, + mime_type: str, + ) -> int: + end = start + len(chunk) - 1 + for attempt in range(4): + try: + response = await self._client.put( + upload_url, + content=chunk, + headers={ + "Content-Type": mime_type, + "Content-Length": str(len(chunk)), + "Content-Range": f"bytes {start}-{end}/{total}", + }, + ) + except (httpx.TimeoutException, httpx.RequestError) as exc: + reconciled = await self._reconcile_uploaded_bytes( + token, publish_id, start + ) + if reconciled > start: + return reconciled + if attempt == 3: + raise SocialProviderUnavailableError( + "TikTok upload chunk could not be delivered." + ) from exc + await asyncio.sleep(2**attempt) + continue + if response.status_code == 201: + return total + if response.status_code == 206: + received = self._range_position(response.headers.get("Content-Range")) + next_position = received if received is not None else end + 1 + if next_position != end + 1: + raise SocialProviderUnavailableError( + "TikTok returned an inconsistent upload byte range." + ) + return next_position + if response.status_code in _RETRYABLE: + reconciled = await self._reconcile_uploaded_bytes( + token, publish_id, start + ) + if reconciled > start: + return reconciled + if attempt < 3: + await asyncio.sleep(2**attempt) + continue + raise SocialProviderUnavailableError( + "TikTok upload is temporarily unavailable." + ) + if response.status_code == 401: + raise SocialReauthRequiredError( + "The TikTok upload session requires reauthorization." + ) + if response.status_code in {403, 404}: + if response.status_code == 403: + raise SocialPermissionDeniedError( + "TikTok denied permission to upload this post." + ) + raise SocialPublishFailedError( + "TikTok upload session expired before completion." + ) + if response.status_code in {400, 416}: + raise SocialMediaInvalidError( + "TikTok rejected the upload byte range or media chunk." + ) + raise SocialPublishFailedError("TikTok upload request failed.") + raise SocialProviderUnavailableError( + "TikTok upload retry budget was exhausted." + ) + + async def _reconcile_uploaded_bytes( + self, token: dict[str, Any], publish_id: str, fallback: int + ) -> int: + status = await self.get_publish_status(token, publish_id) + metadata = ( + status.get("metadata") + if isinstance(status.get("metadata"), dict) + else {} + ) + uploaded = metadata.get("uploaded_bytes") + return uploaded if isinstance(uploaded, int) and uploaded >= 0 else fallback + + async def _token_request(self, form: dict[str, str]) -> dict[str, Any]: + try: + response = await self._client.post(_TOKEN_ENDPOINT, data=form) + except httpx.TransportError as exc: + raise SocialProviderUnavailableError( + "TikTok OAuth is temporarily unavailable." + ) from exc + payload = self._response_payload(response, operation="OAuth") + self._raise_tiktok_error(response, payload, operation="OAuth") + if not isinstance(payload.get("access_token"), str) or not payload["access_token"]: + raise SocialReauthRequiredError("TikTok did not return an access token.") + return payload + + async def _authorized_get( + self, + url: str, + token: dict[str, Any], + *, + params: dict[str, str], + operation: str, + ) -> dict[str, Any]: + response = await self._authorized_request( + "GET", url, token, params=params + ) + payload = self._response_payload(response, operation=operation) + self._raise_tiktok_error(response, payload, operation=operation) + return payload + + async def _authorized_post( + self, + url: str, + token: dict[str, Any], + *, + json: dict[str, object], + operation: str, + ) -> dict[str, Any]: + response = await self._authorized_request( + "POST", url, token, json=json + ) + payload = self._response_payload(response, operation=operation) + self._raise_tiktok_error(response, payload, operation=operation) + return payload + + async def _authorized_request( + self, + method: str, + url: str, + token: dict[str, Any], + **kwargs: Any, + ) -> httpx.Response: + access_token = self._access_token(token) + headers = dict(kwargs.pop("headers", {})) + headers["Authorization"] = f"Bearer {access_token}" + headers.setdefault("Content-Type", "application/json; charset=UTF-8") + try: + return await self._client.request( + method, url, headers=headers, **kwargs + ) + except httpx.TransportError as exc: + raise SocialProviderUnavailableError( + "TikTok provider request is temporarily unavailable." + ) from exc + + def _validate_post_info( + self, + post_info: dict[str, object], + creator: dict[str, Any], + media: dict[str, Any], + ) -> None: + privacy = post_info.get("privacy_level") + options = creator.get("privacy_level_options") + if not isinstance(options, list) or privacy not in options: + raise SocialPermissionDeniedError( + "The selected TikTok privacy level is unavailable for this creator." + ) + for field in ("comment", "duet", "stitch"): + if creator.get(f"{field}_disabled") and not post_info.get( + f"disable_{field}" + ): + raise SocialPermissionDeniedError( + f"TikTok requires {field} to remain disabled for this creator." + ) + probe = media.get("probe") if isinstance(media.get("probe"), dict) else {} + duration = probe.get("duration") + maximum = creator.get("max_video_post_duration_sec") + if ( + isinstance(duration, (int, float)) + and isinstance(maximum, (int, float)) + and duration > maximum + ): + raise SocialMediaInvalidError( + "TikTok video exceeds this creator's current duration limit." + ) + cover = post_info.get("video_cover_timestamp_ms") + if ( + isinstance(cover, int) + and isinstance(duration, (int, float)) + and cover >= duration * 1000 + ): + raise SocialMediaInvalidError( + "TikTok cover timestamp must fall within the video duration." + ) + + if ( + post_info.get("brand_content_toggle") + and privacy != "PUBLIC_TO_EVERYONE" + ): + raise SocialPermissionDeniedError( + "TikTok branded content requires public visibility." + ) + + def _ensure_direct_post_enabled(self) -> None: + if ( + not self.settings.tiktok_direct_post_enabled + or not self.configuration_ready + ): + raise SocialCapabilityUnsupportedError( + "TikTok Direct Post is not enabled for this approved application." + ) + + def _chunk_plan(self, total: int) -> tuple[int, int]: + if total <= _MAX_CHUNK: + return total, 1 + chunk_size = min( + max(_MIN_CHUNK, self.settings.tiktok_upload_chunk_bytes), _MAX_CHUNK + ) + count = total // chunk_size + final_size = total - (count - 1) * chunk_size + if count < 1 or count > 1000 or final_size > _MAX_FINAL_CHUNK: + raise SocialMediaInvalidError( + "TikTok video cannot be represented by a supported upload chunk plan." + ) + return chunk_size, count + + @staticmethod + def _range_position(value: str | None) -> int | None: + if not value: + return None + match = _CONTENT_RANGE.search(value) + return int(match.group(1)) + 1 if match else None + + @staticmethod + def _is_upload_url(value: str) -> bool: + parsed = urlparse(value) + return ( + parsed.scheme == "https" + and parsed.username is None + and parsed.password is None + and parsed.hostname is not None + and parsed.hostname == "open-upload.tiktokapis.com" + and parsed.path.startswith("/video/") + ) + + @staticmethod + def _access_token(token: dict[str, Any]) -> str: + access_token = token.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise SocialReauthRequiredError( + "The TikTok account requires reauthorization." + ) + return access_token + + @staticmethod + def _response_payload( + response: httpx.Response, *, operation: str + ) -> dict[str, Any]: + try: + payload = response.json() + except ValueError as exc: + raise SocialProviderUnavailableError( + f"TikTok returned an invalid {operation} response." + ) from exc + if not isinstance(payload, dict): + raise SocialProviderUnavailableError( + f"TikTok returned an invalid {operation} response." + ) + return payload + + @staticmethod + def _raise_tiktok_error( + response: httpx.Response, payload: dict[str, Any], *, operation: str + ) -> None: + error = payload.get("error") + if isinstance(error, dict): + code = str(error.get("code", "")).lower() + elif isinstance(error, str): + code = error.lower() + else: + code = "" + if response.is_success and code in {"", "ok"}: + return + if response.status_code == 401 or code in { + "access_token_invalid", + "access_token_expired", + "authorization_revoked", + "auth_removed", + "invalid_grant", + "invalid_code", + "invalid_token", + }: + raise SocialReauthRequiredError( + "The TikTok account requires reauthorization." + ) + if operation == "OAuth" and response.status_code == 400: + if code in {"access_denied", "invalid_scope"}: + raise SocialPermissionDeniedError( + "TikTok OAuth authorization was denied." + ) + if code in {"invalid_request", "invalid_authorization_code"}: + raise SocialReauthRequiredError( + "The TikTok authorization code is invalid or expired." + ) + if response.status_code == 403 or code in { + "scope_not_authorized", + "permission_denied", + "access_not_allowed", + "token_not_authorized_for_specified_publish_id", + }: + raise SocialPermissionDeniedError( + f"TikTok {operation} permission was denied." + ) + if response.status_code == 429 or "rate" in code or "quota" in code: + raise SocialRateLimitedError( + f"TikTok {operation} rate limit reached." + ) + if response.status_code >= 500 or code in { + "internal", + "internal_error", + "server_error", + }: + raise SocialProviderUnavailableError( + f"TikTok {operation} is temporarily unavailable." + ) + if code in _MEDIA_FAILURES or code in { + "invalid_file_upload", + "video_size_check_failed", + }: + raise SocialMediaInvalidError( + "TikTok rejected media that does not meet its restrictions." + ) + if response.status_code == 400 or code in { + "invalid_param", + "invalid_request", + "spam_risk", + "spam_risk_text", + "spam_risk_too_many_posts", + "spam_risk_user_banned_from_posting", + }: + raise SocialPublishFailedError( + f"TikTok rejected the {operation} request." + ) + raise SocialProviderUnavailableError( + f"TikTok {operation} request was rejected." + ) diff --git a/app/social/providers/whatsapp.py b/app/social/providers/whatsapp.py new file mode 100644 index 0000000000000000000000000000000000000000..09a9f35d3de15bd6ef99017f4a5f0cca10da4c83 --- /dev/null +++ b/app/social/providers/whatsapp.py @@ -0,0 +1,13 @@ +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.providers.base import SocialProviderAdapter + + +class WhatsAppProvider(SocialProviderAdapter): + def __init__(self, settings: Settings) -> None: + self.capabilities = ProviderCapabilities( + provider=Provider.WHATSAPP, connection_strategy=ConnectionStrategy.BUSINESS_API, + account_types=["business_account", "phone_number"], + implementation_status="registered", + ) diff --git a/app/social/providers/x.py b/app/social/providers/x.py new file mode 100644 index 0000000000000000000000000000000000000000..fef4160592f95a0553b43312e60802d89ee01c15 --- /dev/null +++ b/app/social/providers/x.py @@ -0,0 +1,15 @@ +from app.core.config import Settings +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.providers.oauth import OAuthFoundationAdapter + + +class XProvider(OAuthFoundationAdapter): + authorization_endpoint = "https://twitter.com/i/oauth2/authorize" + + def __init__(self, settings: Settings) -> None: + self.client_id = settings.x_client_id + self.capabilities = ProviderCapabilities( + provider=Provider.X, connection_strategy=ConnectionStrategy.OAUTH, + implementation_status="registered", account_types=["user"], + ) diff --git a/app/social/providers/youtube.py b/app/social/providers/youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..37a6ae636c608efe6d7fffe95fbcc09a09f9efb7 --- /dev/null +++ b/app/social/providers/youtube.py @@ -0,0 +1,630 @@ +from __future__ import annotations + +import asyncio +import re +import time +from pathlib import Path +from typing import Any +from urllib.parse import urlencode, urlparse + +import aiofiles +import httpx + +from app.core.config import Settings +from app.core.logger import get_logger +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import ConnectionStrategy, Provider +from app.social.domain.errors import ( + SocialMediaInvalidError, + SocialPermissionDeniedError, + SocialProviderQuotaError, + SocialProviderUnavailableError, + SocialPublishFailedError, + SocialRateLimitedError, + SocialReauthRequiredError, +) +from app.social.providers.oauth import OAuthFoundationAdapter + +logger = get_logger(__name__) + +_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +_GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke" +_YOUTUBE_API = "https://www.googleapis.com/youtube/v3" +_YOUTUBE_UPLOAD = "https://www.googleapis.com/upload/youtube/v3/videos" +_RETRYABLE = frozenset({429, 500, 502, 503, 504}) +_RANGE = re.compile(r"bytes=0-(\d+)") + + +class YouTubeProvider(OAuthFoundationAdapter): + """Official YouTube Data API v3 adapter. + + All provider requests use an access token supplied by TokenService through + the social worker/service boundary; this class never persists or exposes + credentials. Resumable-session URIs enter and leave only encrypted worker + state through the callback supplied to ``upload_media``. + """ + + authorization_endpoint = "https://accounts.google.com/o/oauth2/v2/auth" + + def __init__( + self, + settings: Settings, + *, + http_client: httpx.AsyncClient | None = None, + ) -> None: + self.settings = settings + self.reconciliation_poll_seconds = settings.youtube_processing_poll_seconds + self.client_id = settings.google_client_id + self._client_secret = ( + settings.google_client_secret.get_secret_value() + if settings.google_client_secret + else "" + ) + self._client = http_client or httpx.AsyncClient( + timeout=httpx.Timeout(settings.youtube_request_timeout_seconds), + follow_redirects=False, + ) + self._owns_client = http_client is None + self._uploads = asyncio.Semaphore(settings.youtube_max_concurrent_uploads) + self.capabilities = ProviderCapabilities( + provider=Provider.YOUTUBE, + connection_strategy=ConnectionStrategy.OAUTH, + video=True, + video_upload=True, + video_status=True, + channel_metadata=True, + direct_publish=True, + # Scheduled posts are dispatched by the existing durable + # MediaRouter scheduler. Native YouTube `publishAt` is also typed + # and sent when explicitly supplied. + scheduled_publish=True, + analytics=True, + delete_post=True, + personal_publishing=True, + implementation_status="implemented", + account_types=["channel"], + required_scopes=["https://www.googleapis.com/auth/youtube.upload"], + ) + + async def close(self) -> None: + if self._owns_client: + await self._client.aclose() + + async def get_authorization_url( + self, + *, + state: str, + redirect_uri: str, + code_challenge: str | None = None, + additional_scopes: list[str] | None = None, + ) -> str: + if not self.client_id: + raise SocialProviderUnavailableError("youtube OAuth credentials are not configured.") + params = { + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "state": state, + "scope": " ".join( + dict.fromkeys( + [*self.capabilities.required_scopes, *(additional_scopes or [])] + ) + ), + # Offline access is necessary for workers to publish after the + # browser callback has ended. `prompt=consent` makes reconnecting + # an account reliably return a new refresh token. + "access_type": "offline", + "prompt": "consent", + "include_granted_scopes": "false", + } + if code_challenge: + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + return f"{self.authorization_endpoint}?{urlencode(params)}" + + async def exchange_code( + self, *, code: str, redirect_uri: str, code_verifier: str | None = None + ) -> dict[str, Any]: + if not self.client_id or not self._client_secret: + raise SocialProviderUnavailableError("youtube OAuth credentials are not configured.") + if not code_verifier: + raise SocialPermissionDeniedError("YouTube OAuth PKCE verification is required.") + response = await self._request( + "POST", + _GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": self.client_id, + "client_secret": self._client_secret, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "code_verifier": code_verifier, + }, + oauth=True, + ) + payload = self._json(response) + if not payload.get("access_token"): + raise SocialReauthRequiredError("Google did not return an access token.") + return payload + + async def refresh_token(self, token: dict[str, Any]) -> dict[str, Any]: + refresh_token = token.get("refresh_token") + if not isinstance(refresh_token, str) or not refresh_token: + raise SocialReauthRequiredError("The YouTube account has no refresh token.") + if not self.client_id or not self._client_secret: + raise SocialProviderUnavailableError("youtube OAuth credentials are not configured.") + response = await self._request( + "POST", + _GOOGLE_TOKEN_URL, + data={ + "client_id": self.client_id, + "client_secret": self._client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + oauth=True, + ) + refreshed = self._json(response) + if not refreshed.get("access_token"): + raise SocialReauthRequiredError("Google did not return a refreshed access token.") + # Google normally omits refresh_token on refresh. TokenService merges + # it with the existing encrypted credential before persistence. + return refreshed + + async def revoke_token(self, token: dict[str, Any]) -> None: + value = token.get("refresh_token") or token.get("access_token") + if not isinstance(value, str) or not value: + return + response = await self._client.post(_GOOGLE_REVOKE_URL, data={"token": value}) + if response.status_code not in {200, 204, 400}: + self._raise_provider_error(response) + + async def get_account(self, token: dict[str, Any]) -> dict[str, Any]: + response = await self._api_request( + "GET", + f"{_YOUTUBE_API}/channels", + token, + params={"part": "snippet", "mine": "true", "maxResults": "1"}, + ) + items = self._json(response).get("items") + if not isinstance(items, list) or not items or not isinstance(items[0], dict): + raise SocialPermissionDeniedError("The authorized Google account has no accessible YouTube channel.") + channel = items[0] + channel_id = channel.get("id") + snippet = channel.get("snippet") if isinstance(channel.get("snippet"), dict) else {} + if not isinstance(channel_id, str) or not channel_id: + raise SocialPublishFailedError("YouTube returned a channel without a stable channel ID.") + thumbnails = snippet.get("thumbnails") if isinstance(snippet.get("thumbnails"), dict) else {} + avatar = None + for size in ("high", "medium", "default"): + candidate = thumbnails.get(size) + if isinstance(candidate, dict) and isinstance(candidate.get("url"), str): + avatar = candidate["url"] + break + custom_url = snippet.get("customUrl") + return { + "external_account_id": channel_id, + "account_type": "channel", + "display_name": snippet.get("title") if isinstance(snippet.get("title"), str) else channel_id, + "username": custom_url if isinstance(custom_url, str) else None, + "avatar_url": avatar, + "metadata": { + "channel_id": channel_id, + "published_at": snippet.get("publishedAt"), + "country": snippet.get("country"), + }, + } + + async def get_capabilities(self) -> ProviderCapabilities: + """Return the exact supported YouTube contract; no inferred features.""" + return self.capabilities + + async def validate_media(self, media: dict[str, Any]) -> None: + path = media.get("path") + mime_type = str(media.get("mime_type") or "") + size = media.get("file_size") + probe = media.get("probe") if isinstance(media.get("probe"), dict) else {} + if not isinstance(path, Path) or not path.is_file(): + raise SocialMediaInvalidError("YouTube media asset is not readable.") + if not mime_type.startswith("video/"): + raise SocialMediaInvalidError("YouTube accepts video media only.") + if not isinstance(size, int) or size <= 0 or size > self.settings.max_upload_size: + raise SocialMediaInvalidError("YouTube media file size is invalid or exceeds the configured limit.") + container = str(probe.get("container") or "").lower() + if not any(value in container for value in ("mp4", "quicktime", "matroska", "webm", "mpeg")): + raise SocialMediaInvalidError("YouTube media container is unsupported; create a compatible MediaRouter variant.") + video_streams = probe.get("video_streams") if isinstance(probe.get("video_streams"), list) else [] + if not video_streams: + raise SocialMediaInvalidError("YouTube media must contain a video stream.") + codec = str(video_streams[0].get("codec") or "").lower() if isinstance(video_streams[0], dict) else "" + if codec not in {"h264", "hevc", "vp8", "vp9", "av1", "mpeg4"}: + raise SocialMediaInvalidError("YouTube video codec is unsupported; create a compatible MediaRouter variant.") + duration = probe.get("duration") + if not isinstance(duration, (int, float)) or duration <= 0: + raise SocialMediaInvalidError("YouTube media must have a positive duration.") + resolution = probe.get("resolution") if isinstance(probe.get("resolution"), dict) else {} + width, height = resolution.get("width"), resolution.get("height") + if not isinstance(width, int) or not isinstance(height, int) or width < 1 or height < 1: + raise SocialMediaInvalidError("YouTube media must have valid dimensions.") + + async def upload_media(self, token: dict[str, Any], media: dict[str, Any]) -> dict[str, Any]: + await self.validate_media(media) + resource = media.get("youtube_resource") + if not isinstance(resource, dict): + raise SocialPublishFailedError("Typed YouTube metadata is required to upload a video.") + path = media["path"] + assert isinstance(path, Path) + total = int(media["file_size"]) + mime_type = str(media["mime_type"]) + persist_session = media.get("persist_upload_session") + if persist_session is not None and not callable(persist_session): + raise SocialPublishFailedError("YouTube upload session persistence is invalid.") + heartbeat = media.get("heartbeat") + if heartbeat is not None and not callable(heartbeat): + raise SocialPublishFailedError("YouTube upload heartbeat is invalid.") + session_url = media.get("upload_session_url") + if session_url is not None and not self._is_google_upload_url(str(session_url)): + raise SocialPublishFailedError("Stored YouTube upload session is invalid.") + + async with self._uploads: + started = time.monotonic() + if isinstance(session_url, str) and session_url: + resumed = await self._resume_position(session_url, total, token) + if isinstance(resumed, dict): + self._log("youtube_upload_completed", bytes=total, resumed=True, elapsed=time.monotonic() - started) + return self._video_result(resumed) + if resumed is None: + session_url = None + if persist_session: + await persist_session(None) + else: + position = resumed + if not session_url: + session_url = await self._initialize_upload( + token, + resource, + total, + mime_type, + bool(media.get("notify_subscribers", True)), + ) + if persist_session: + await persist_session(session_url) + position = 0 + self._log("youtube_upload_started", bytes=total, resumed=position > 0) + try: + return await self._upload_chunks( + token, session_url, path, total, mime_type, position, heartbeat + ) + except Exception: + self._log("youtube_upload_failed", bytes=total, elapsed=time.monotonic() - started) + raise + + async def publish(self, token: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + # Video insertion is the publish operation in YouTube Data API v3. + # Returning the durable video identifier is deliberately not a claim + # that YouTube processing/publication has completed; the worker always + # calls get_publish_status before marking a target PUBLISHED. + upload = payload.get("upload") + if not isinstance(upload, dict) or not isinstance(upload.get("id"), str): + raise SocialPublishFailedError("YouTube upload did not return a video ID.") + return dict(upload) + + async def get_publish_status(self, token: dict[str, Any], external_id: str) -> dict[str, Any]: + response = await self._api_request( + "GET", + f"{_YOUTUBE_API}/videos", + token, + params={"part": "snippet,status,processingDetails", "id": external_id}, + ) + items = self._json(response).get("items") + if not isinstance(items, list) or not items: + return {"id": external_id, "status": "deleted", "metadata": {"reason": "not_found"}} + video = items[0] if isinstance(items[0], dict) else {} + status = video.get("status") if isinstance(video.get("status"), dict) else {} + processing = video.get("processingDetails") if isinstance(video.get("processingDetails"), dict) else {} + upload_status = str(status.get("uploadStatus") or "") + processing_status = str(processing.get("processingStatus") or "") + if upload_status in {"failed", "rejected"} or processing_status in {"failed", "terminated"}: + normalized = "failed" + elif upload_status == "deleted": + normalized = "deleted" + elif processing_status in {"processing", "uploading"} or upload_status == "uploaded" or upload_status == "processed" and processing_status not in {"succeeded", ""}: + normalized = "processing" + elif upload_status in {"processed", "uploaded"} or processing_status == "succeeded": + normalized = "published" + else: + normalized = "unavailable" + snippet = video.get("snippet") if isinstance(video.get("snippet"), dict) else {} + return { + "id": str(video.get("id") or external_id), + "url": f"https://www.youtube.com/watch?v={video.get('id') or external_id}", + "status": normalized, + "published_at": snippet.get("publishedAt"), + "metadata": { + "privacy_status": status.get("privacyStatus"), + "upload_status": upload_status or None, + "processing_status": processing_status or None, + "failure_reason": processing.get("processingFailureReason"), + "rejection_reason": status.get("rejectionReason"), + }, + } + + async def delete_post(self, token: dict[str, Any], external_id: str) -> None: + try: + response = await self._client.request( + "DELETE", + f"{_YOUTUBE_API}/videos", + params={"id": external_id}, + headers={"Authorization": self._bearer(token)}, + ) + except httpx.TimeoutException as exc: + raise SocialProviderUnavailableError("YouTube provider request timed out.") from exc + except httpx.RequestError as exc: + raise SocialProviderUnavailableError("YouTube provider request failed.") from exc + # Deletion is idempotent from MediaRouter's perspective. A video that + # was already removed must not keep a tenant unable to remove its local + # SocialPost record. + if response.status_code in {200, 204, 404}: + return + if response.status_code >= 400: + self._raise_provider_error(response) + + async def get_metrics(self, token: dict[str, Any], external_id: str) -> dict[str, Any]: + response = await self._api_request( + "GET", + f"{_YOUTUBE_API}/videos", + token, + params={"part": "statistics,snippet,status", "id": external_id}, + ) + items = self._json(response).get("items") + if not isinstance(items, list) or not items: + return {"id": external_id, "status": "unavailable", "raw_metrics": {}} + video = items[0] if isinstance(items[0], dict) else {} + statistics = video.get("statistics") if isinstance(video.get("statistics"), dict) else {} + snippet = video.get("snippet") if isinstance(video.get("snippet"), dict) else {} + raw = { + key: value + for key, value in statistics.items() + if key in {"viewCount", "likeCount", "commentCount", "favoriteCount"} + } + return { + "id": str(video.get("id") or external_id), + "status": "available", + "views": self._int(statistics.get("viewCount")), + "likes": self._int(statistics.get("likeCount")), + "comments": self._int(statistics.get("commentCount")), + "published_at": snippet.get("publishedAt"), + "raw_metrics": raw, + } + + async def _initialize_upload( + self, + token: dict[str, Any], + resource: dict[str, Any], + total: int, + mime_type: str, + notify_subscribers: bool, + ) -> str: + response = await self._api_request( + "POST", + _YOUTUBE_UPLOAD, + token, + params={"uploadType": "resumable", "part": "snippet,status", "notifySubscribers": str(notify_subscribers).lower()}, + json=resource, + headers={ + "X-Upload-Content-Length": str(total), + "X-Upload-Content-Type": mime_type, + }, + ) + location = response.headers.get("Location") + if not location or not self._is_google_upload_url(location): + raise SocialPublishFailedError("YouTube did not create a valid resumable upload session.") + return location + + async def _resume_position(self, session_url: str, total: int, token: dict[str, Any]) -> int | dict[str, Any] | None: + for retry in range(4): + try: + response = await self._client.put( + session_url, + headers={ + "Authorization": self._bearer(token), + "Content-Length": "0", + "Content-Range": f"bytes */{total}", + }, + ) + except httpx.TimeoutException as exc: + if retry == 3: + raise SocialProviderUnavailableError("YouTube resumable upload status timed out.") from exc + await asyncio.sleep(2**retry) + continue + except httpx.RequestError as exc: + if retry == 3: + raise SocialProviderUnavailableError("YouTube resumable upload status could not be reached.") from exc + await asyncio.sleep(2**retry) + continue + if response.status_code in {200, 201}: + return self._json(response) + if response.status_code == 308: + match = _RANGE.fullmatch(response.headers.get("Range", "")) + return int(match.group(1)) + 1 if match else 0 + if response.status_code in {404, 410}: + return None + if response.status_code in _RETRYABLE and retry < 3: + await asyncio.sleep(2**retry) + continue + self._raise_provider_error(response) + raise SocialProviderUnavailableError("YouTube resumable upload status could not be reconciled.") + + async def _upload_chunks( + self, + token: dict[str, Any], + session_url: str, + path: Path, + total: int, + mime_type: str, + position: int, + heartbeat: Any, + ) -> dict[str, Any]: + async with aiofiles.open(path, "rb") as media: + await media.seek(position) + while position < total: + if heartbeat is not None: + await heartbeat() + chunk = await media.read(min(self.settings.youtube_upload_chunk_bytes, total - position)) + if not chunk: + raise SocialPublishFailedError("YouTube upload file ended before its registered size.") + end = position + len(chunk) - 1 + response = await self._put_chunk_with_resume( + token, session_url, chunk, position, end, total, mime_type + ) + if isinstance(response, dict): + self._log("youtube_upload_completed", bytes=total) + return self._video_result(response) + next_position = response + if next_position < position: + raise SocialPublishFailedError("YouTube resumable upload returned an invalid byte range.") + position = next_position + await media.seek(position) + raise SocialPublishFailedError("YouTube upload ended without a video result.") + + async def _put_chunk_with_resume( + self, token: dict[str, Any], session_url: str, chunk: bytes, start: int, end: int, total: int, mime_type: str + ) -> int | dict[str, Any]: + for retry in range(5): + try: + response = await self._client.put( + session_url, + content=chunk, + headers={ + "Authorization": self._bearer(token), + "Content-Type": mime_type, + "Content-Length": str(len(chunk)), + "Content-Range": f"bytes {start}-{end}/{total}", + }, + ) + except (httpx.TimeoutException, httpx.RequestError) as exc: + if retry == 4: + raise SocialProviderUnavailableError("YouTube upload chunk could not be delivered.") from exc + reconciled = await self._resume_position(session_url, total, token) + if isinstance(reconciled, dict): + return reconciled + if reconciled is None: + raise SocialProviderUnavailableError("YouTube resumable upload session expired.") from exc + if reconciled > start: + return reconciled + await asyncio.sleep(2**retry) + continue + if response.status_code in {200, 201}: + return self._json(response) + if response.status_code == 308: + match = _RANGE.fullmatch(response.headers.get("Range", "")) + return int(match.group(1)) + 1 if match else 0 + if response.status_code in _RETRYABLE and retry < 4: + reconciled = await self._resume_position(session_url, total, token) + if isinstance(reconciled, dict): + return reconciled + if reconciled is None: + raise SocialProviderUnavailableError("YouTube resumable upload session expired.") + if reconciled > start: + return reconciled + await asyncio.sleep(2**retry) + continue + self._raise_provider_error(response) + raise SocialProviderUnavailableError("YouTube upload chunk retry budget was exhausted.") + + async def _api_request(self, method: str, url: str, token: dict[str, Any], **kwargs: Any) -> httpx.Response: + headers = dict(kwargs.pop("headers", {})) + headers["Authorization"] = self._bearer(token) + return await self._request(method, url, headers=headers, **kwargs) + + async def _request(self, method: str, url: str, *, oauth: bool = False, **kwargs: Any) -> httpx.Response: + try: + response = await self._client.request(method, url, **kwargs) + except httpx.TimeoutException as exc: + raise SocialProviderUnavailableError("YouTube provider request timed out.") from exc + except httpx.RequestError as exc: + raise SocialProviderUnavailableError("YouTube provider request failed.") from exc + if response.status_code >= 400: + self._raise_provider_error(response, oauth=oauth) + return response + + @staticmethod + def _json(response: httpx.Response) -> dict[str, Any]: + try: + value = response.json() + except ValueError as exc: + raise SocialPublishFailedError("YouTube returned an invalid response.") from exc + if not isinstance(value, dict): + raise SocialPublishFailedError("YouTube returned an invalid response.") + return value + + def _raise_provider_error(self, response: httpx.Response, *, oauth: bool = False) -> None: + try: + payload = response.json() + except ValueError: + payload = {} + error = payload.get("error") if isinstance(payload, dict) else {} + detail = error if isinstance(error, dict) else {} + reasons = { + item.get("reason") + for item in detail.get("errors", []) + if isinstance(item, dict) and isinstance(item.get("reason"), str) + } + logger.warning( + "youtube_provider_error", + extra={ + "provider": "youtube", + "provider_status": response.status_code, + "provider_reasons": sorted(reasons), + "oauth": oauth, + }, + ) + # Never pass arbitrary upstream text through. Google messages may + # contain user-provided metadata; stable messages remain safe to log. + if response.status_code == 401: + raise SocialReauthRequiredError("YouTube authorization is no longer valid.") + if response.status_code == 403: + if reasons & {"quotaExceeded", "dailyLimitExceeded", "userRateLimitExceeded"}: + raise SocialProviderQuotaError("YouTube API quota is exhausted.") + raise SocialPermissionDeniedError("YouTube denied the requested operation.") + if response.status_code == 429: + if reasons & {"quotaExceeded", "dailyLimitExceeded"}: + raise SocialProviderQuotaError("YouTube API quota is exhausted.") + raise SocialRateLimitedError("YouTube rate limit was reached.") + if response.status_code in {500, 502, 503, 504}: + raise SocialProviderUnavailableError("YouTube is temporarily unavailable.") + if oauth and response.status_code == 400: + raise SocialReauthRequiredError("Google rejected the OAuth authorization response.") + if response.status_code == 400: + raise SocialPublishFailedError("YouTube rejected the supplied video metadata or upload request.") + raise SocialPublishFailedError("YouTube provider request failed.") + + @staticmethod + def _video_result(video: dict[str, Any]) -> dict[str, Any]: + video_id = video.get("id") + if not isinstance(video_id, str) or not video_id: + raise SocialPublishFailedError("YouTube upload completed without a video ID.") + return {"id": video_id, "url": f"https://www.youtube.com/watch?v={video_id}"} + + @staticmethod + def _bearer(token: dict[str, Any]) -> str: + access_token = token.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise SocialReauthRequiredError("The YouTube account has no access token.") + return f"Bearer {access_token}" + + @staticmethod + def _is_google_upload_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme == "https" and parsed.hostname in {"www.googleapis.com", "upload.youtube.com"} and parsed.path.startswith("/upload/youtube/") + + @staticmethod + def _int(value: object) -> int | None: + try: + return int(str(value)) + except (TypeError, ValueError): + return None + + @staticmethod + def _log(event: str, **fields: Any) -> None: + logger.info(event, extra={"provider": "youtube", **fields}) diff --git a/app/social/repositories/__init__.py b/app/social/repositories/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..621014bb456d7d22480f24944281bda37a1daa22 --- /dev/null +++ b/app/social/repositories/__init__.py @@ -0,0 +1,6 @@ +from app.social.repositories.accounts import AccountRepository +from app.social.repositories.jobs import JobRepository +from app.social.repositories.posts import PostRepository +from app.social.repositories.tokens import TokenRepository + +__all__ = ["AccountRepository", "JobRepository", "PostRepository", "TokenRepository"] diff --git a/app/social/repositories/accounts.py b/app/social/repositories/accounts.py new file mode 100644 index 0000000000000000000000000000000000000000..f5689b63869a39dcee6a603802df8860366a73f3 --- /dev/null +++ b/app/social/repositories/accounts.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from sqlalchemy import select + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialAccountNotFoundError +from app.social.models import SocialAccount + + +class AccountRepository: + def __init__(self, database: SocialDatabase) -> None: + self.database = database + + async def list(self, workspace_id: str, *, offset: int = 0, limit: int = 100) -> list[SocialAccount]: + async with self.database.session(workspace_id) as session: + return list((await session.scalars(select(SocialAccount).where(SocialAccount.workspace_id == workspace_id).order_by(SocialAccount.created_at.desc()).offset(offset).limit(limit))).all()) + + async def get(self, workspace_id: str, account_id: str) -> SocialAccount: + async with self.database.session(workspace_id) as session: + record = await session.scalar(select(SocialAccount).where(SocialAccount.id == account_id, SocialAccount.workspace_id == workspace_id)) + if record is None: + raise SocialAccountNotFoundError("Social account was not found.") + return record + + async def create(self, record: SocialAccount) -> SocialAccount: + async with self.database.session(record.workspace_id) as session: + session.add(record) + await session.commit() + await session.refresh(record) + return record + + async def get_by_external( + self, workspace_id: str, provider: str, external_account_id: str + ) -> SocialAccount | None: + async with self.database.session(workspace_id) as session: + return await session.scalar( + select(SocialAccount).where( + SocialAccount.workspace_id == workspace_id, + SocialAccount.provider == provider, + SocialAccount.external_account_id == external_account_id, + ) + ) + + async def update_connection( + self, workspace_id: str, account_id: str, *, account_type: str, + username: str | None, display_name: str | None, avatar_url: str | None, + metadata: dict[str, object], + ) -> SocialAccount: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialAccount).where( + SocialAccount.id == account_id, + SocialAccount.workspace_id == workspace_id, + ) + ) + if record is None: + raise SocialAccountNotFoundError("Social account was not found.") + record.account_type = account_type + record.username = username + record.display_name = display_name + record.avatar_url = avatar_url + record.metadata_json = metadata + record.status = "connected" + await session.commit() + await session.refresh(record) + return record + + async def disconnect(self, workspace_id: str, account_id: str) -> SocialAccount: + async with self.database.session(workspace_id) as session: + record = await session.scalar(select(SocialAccount).where(SocialAccount.id == account_id, SocialAccount.workspace_id == workspace_id)) + if record is None: + raise SocialAccountNotFoundError("Social account was not found.") + record.status = "disconnected" + await session.commit() + return record + + async def set_status( + self, workspace_id: str, account_id: str, status: str + ) -> SocialAccount: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialAccount).where( + SocialAccount.id == account_id, + SocialAccount.workspace_id == workspace_id, + ) + ) + if record is None: + raise SocialAccountNotFoundError("Social account was not found.") + record.status = status + await session.commit() + return record diff --git a/app/social/repositories/assets.py b/app/social/repositories/assets.py new file mode 100644 index 0000000000000000000000000000000000000000..2aed4d74dde5dae57fec2b0cc99bed1d8e59bc95 --- /dev/null +++ b/app/social/repositories/assets.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialMediaInvalidError +from app.social.models import SocialMediaAsset + + +class SocialMediaAssetRepository: + def __init__(self, database: SocialDatabase) -> None: + self.database = database + + async def get(self, workspace_id: str, asset_id: str) -> SocialMediaAsset: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialMediaAsset).where( + SocialMediaAsset.id == asset_id, + SocialMediaAsset.workspace_id == workspace_id, + ) + ) + if record is None: + raise SocialMediaInvalidError("Media asset was not found in this workspace.") + return record + + async def list(self, workspace_id: str, *, offset: int = 0, limit: int = 100) -> list[SocialMediaAsset]: + async with self.database.session(workspace_id) as session: + return list( + ( + await session.scalars( + select(SocialMediaAsset) + .where(SocialMediaAsset.workspace_id == workspace_id) + .order_by(SocialMediaAsset.created_at.desc()) + .offset(offset) + .limit(limit) + ) + ).all() + ) + + async def create(self, record: SocialMediaAsset) -> SocialMediaAsset: + try: + async with self.database.session(record.workspace_id) as session: + session.add(record) + await session.commit() + await session.refresh(record) + return record + except IntegrityError: + async with self.database.session(record.workspace_id) as session: + existing = await session.scalar( + select(SocialMediaAsset).where( + SocialMediaAsset.workspace_id == record.workspace_id, + SocialMediaAsset.request_id == record.request_id, + SocialMediaAsset.filename == record.filename, + ) + ) + if existing is None: + raise + return existing diff --git a/app/social/repositories/jobs.py b/app/social/repositories/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..c8b4df539ea912e128ab263c24d1cb29a0020918 --- /dev/null +++ b/app/social/repositories/jobs.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, or_, select +from sqlalchemy.exc import IntegrityError + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialJobNotFoundError +from app.social.domain.state_machine import validate_transition +from app.social.models import SocialJob, SocialJobAttempt +from app.social.oauth.encryption import TokenCipher + + +class JobRepository: + def __init__(self, database: SocialDatabase, cipher: TokenCipher) -> None: + self.database = database + self.cipher = cipher + + async def list( + self, workspace_id: str, *, offset: int = 0, limit: int = 100 + ) -> list[SocialJob]: + async with self.database.session(workspace_id) as session: + return list( + ( + await session.scalars( + select(SocialJob) + .where(SocialJob.workspace_id == workspace_id) + .order_by(SocialJob.created_at.desc()) + .offset(offset) + .limit(limit) + ) + ).all() + ) + + async def get(self, workspace_id: str, job_id: str) -> SocialJob: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + return record + + async def get_by_idempotency( + self, workspace_id: str, idempotency_key: str + ) -> SocialJob | None: + async with self.database.session(workspace_id) as session: + return await session.scalar( + select(SocialJob).where( + SocialJob.workspace_id == workspace_id, + SocialJob.idempotency_key == idempotency_key, + ) + ) + + async def list_for_post(self, workspace_id: str, post_id: str) -> list[SocialJob]: + async with self.database.session(workspace_id) as session: + return list( + ( + await session.scalars( + select(SocialJob).where( + SocialJob.workspace_id == workspace_id, + SocialJob.social_post_id == post_id, + ) + ) + ).all() + ) + + async def create_many(self, jobs: list[SocialJob]) -> list[SocialJob]: + if not jobs: + return [] + try: + async with self.database.session(jobs[0].workspace_id) as session: + session.add_all(jobs) + await session.commit() + for record in jobs: + await session.refresh(record) + return jobs + except IntegrityError: + canonical: list[SocialJob] = [] + for job in jobs: + if not job.idempotency_key: + raise + record = await self.get_by_idempotency( + job.workspace_id, job.idempotency_key + ) + if record is None: + raise + canonical.append(record) + return canonical + + async def transition( + self, + workspace_id: str, + job_id: str, + status: str, + *, + error_code: str | None = None, + error_message: str | None = None, + next_attempt_at: datetime | None = None, + ) -> SocialJob: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + destination = validate_transition(record.status, status) + record.status = destination.value + record.error_code = error_code + record.error_message = error_message + record.next_attempt_at = next_attempt_at + now = datetime.now(timezone.utc) + if destination.value == "preparing" and record.started_at is None: + record.started_at = now + if destination.value in {"published", "failed", "cancelled"}: + record.completed_at = now + await session.commit() + return record + + async def set_provider_state( + self, workspace_id: str, job_id: str, state: dict[str, object] | None + ) -> None: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + record.provider_state_encrypted = self.cipher.encrypt(state) if state else None + await session.commit() + + async def get_provider_state(self, workspace_id: str, job_id: str) -> dict[str, object]: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + value = record.provider_state_encrypted + if not value: + return {} + decoded = self.cipher.decrypt(value) + return decoded if isinstance(decoded, dict) else {} + + async def defer_reconciliation( + self, workspace_id: str, job_id: str, *, next_attempt_at: datetime + ) -> SocialJob: + """Keep provider processing in PUBLISHING without counting a retry.""" + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + if record.status != "publishing": + raise SocialJobNotFoundError("Social job is not awaiting provider reconciliation.") + record.next_attempt_at = next_attempt_at + await session.commit() + await session.refresh(record) + return record + + async def heartbeat(self, workspace_id: str, job_id: str) -> None: + """Renew the active worker lease during a long streaming upload.""" + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + record.updated_at = datetime.now(timezone.utc) + await session.commit() + + async def start_attempt(self, workspace_id: str, job_id: str) -> SocialJobAttempt: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialJob).where( + SocialJob.id == job_id, SocialJob.workspace_id == workspace_id + ) + ) + if record is None: + raise SocialJobNotFoundError("Social job was not found.") + record.attempt_count += 1 + attempt = SocialJobAttempt( + social_job_id=job_id, + attempt_number=record.attempt_count, + status="started", + ) + session.add(attempt) + await session.commit() + await session.refresh(attempt) + return attempt + + async def complete_attempt( + self, + attempt_id: str, + *, + status: str, + error_code: str | None = None, + error_message: str | None = None, + provider_request_id: str | None = None, + ) -> None: + async with self.database.session() as session: + attempt = await session.get(SocialJobAttempt, attempt_id) + if attempt: + attempt.status = status + attempt.error_code = error_code + attempt.error_message = error_message + attempt.provider_request_id = provider_request_id + attempt.completed_at = datetime.now(timezone.utc) + await session.commit() + + async def claim_due( + self, *, limit: int = 50, stale_after_seconds: int = 900 + ) -> list[SocialJob]: + now = datetime.now(timezone.utc) + stale_before = now - timedelta(seconds=stale_after_seconds) + async with self.database.session() as session: + active_statuses = ["preparing", "processing", "uploading", "publishing"] + statement = ( + select(SocialJob) + .where( + or_( + and_( + SocialJob.status.in_(["queued", "retrying"]), + ( + SocialJob.next_attempt_at.is_(None) + | (SocialJob.next_attempt_at <= now) + ), + ), + and_( + SocialJob.status == "publishing", + SocialJob.next_attempt_at.is_not(None), + SocialJob.next_attempt_at <= now, + ), + and_( + SocialJob.status.in_(active_statuses), + SocialJob.updated_at <= stale_before, + ), + ) + ) + .order_by(SocialJob.created_at) + .limit(limit) + .with_for_update(skip_locked=True) + ) + jobs = list((await session.scalars(statement)).all()) + claimed: list[SocialJob] = [] + for job in jobs: + # A provider-side processing poll is not a failed attempt and + # must remain in PUBLISHING. Clear its due timestamp while the + # worker owns this reconciliation pass. + if job.status == "publishing" and job.next_attempt_at and job.next_attempt_at <= now: + job.next_attempt_at = None + claimed.append(job) + continue + if job.status in active_statuses: + if job.attempt_count >= job.max_attempts: + validate_transition(job.status, "failed") + job.status = "failed" + job.error_code = "SOCIAL_WORKER_LEASE_EXPIRED" + job.error_message = "The worker lease expired after the retry limit." + job.completed_at = now + continue + validate_transition(job.status, "retrying") + job.status = "retrying" + job.error_code = "SOCIAL_WORKER_LEASE_EXPIRED" + job.error_message = "The worker lease expired; retrying safely." + job.next_attempt_at = now + validate_transition(job.status, "preparing") + job.status = "preparing" + if job.started_at is None: + job.started_at = now + claimed.append(job) + await session.commit() + return claimed diff --git a/app/social/repositories/posts.py b/app/social/repositories/posts.py new file mode 100644 index 0000000000000000000000000000000000000000..18deb07b04430702e8936e157a8a592b69f4d65a --- /dev/null +++ b/app/social/repositories/posts.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialIdempotencyConflictError, SocialPostNotFoundError +from app.social.models import ( + MediaVariant, + SocialCampaign, + SocialPost, + SocialPostTarget, + SocialSchedule, +) + + +class PostRepository: + def __init__(self, database: SocialDatabase) -> None: + self.database = database + + async def assert_related_resources_owned( + self, + workspace_id: str, + *, + campaign_id: str | None, + variant_id: str | None, + ) -> None: + async with self.database.session(workspace_id) as session: + if campaign_id: + campaign = await session.scalar( + select(SocialCampaign.id).where( + SocialCampaign.id == campaign_id, + SocialCampaign.workspace_id == workspace_id, + ) + ) + if campaign is None: + raise SocialPostNotFoundError("Social campaign was not found.") + if variant_id: + variant = await session.scalar( + select(MediaVariant.id).where( + MediaVariant.id == variant_id, + MediaVariant.workspace_id == workspace_id, + ) + ) + if variant is None: + raise SocialPostNotFoundError("Media variant was not found.") + + async def list( + self, workspace_id: str, *, offset: int = 0, limit: int = 100 + ) -> list[tuple[SocialPost, list[SocialPostTarget]]]: + async with self.database.session(workspace_id) as session: + posts = list( + ( + await session.scalars( + select(SocialPost) + .where(SocialPost.workspace_id == workspace_id) + .order_by(SocialPost.created_at.desc()) + .offset(offset) + .limit(limit) + ) + ).all() + ) + if not posts: + return [] + targets = list( + ( + await session.scalars( + select(SocialPostTarget).where( + SocialPostTarget.social_post_id.in_([post.id for post in posts]) + ) + ) + ).all() + ) + by_post: dict[str, list[SocialPostTarget]] = {} + for target in targets: + by_post.setdefault(target.social_post_id, []).append(target) + return [(post, by_post.get(post.id, [])) for post in posts] + + async def get( + self, workspace_id: str, post_id: str + ) -> tuple[SocialPost, list[SocialPostTarget]]: + async with self.database.session(workspace_id) as session: + post = await session.scalar( + select(SocialPost).where( + SocialPost.id == post_id, SocialPost.workspace_id == workspace_id + ) + ) + if post is None: + raise SocialPostNotFoundError("Social post was not found.") + targets = list( + ( + await session.scalars( + select(SocialPostTarget) + .where(SocialPostTarget.social_post_id == post_id) + .order_by(SocialPostTarget.created_at) + ) + ).all() + ) + return post, targets + + async def get_by_post_id_unscoped( + self, post_id: str + ) -> tuple[SocialPost, list[SocialPostTarget]]: + """Worker-only lookup; API requests must always use the tenant-scoped get.""" + async with self.database.session() as session: + post = await session.get(SocialPost, post_id) + if post is None: + raise SocialPostNotFoundError("Social post was not found.") + targets = list( + ( + await session.scalars( + select(SocialPostTarget).where( + SocialPostTarget.social_post_id == post_id + ) + ) + ).all() + ) + return post, targets + + async def get_by_idempotency( + self, workspace_id: str, idempotency_key: str + ) -> tuple[SocialPost, list[SocialPostTarget]] | None: + async with self.database.session(workspace_id) as session: + post = await session.scalar( + select(SocialPost).where( + SocialPost.workspace_id == workspace_id, + SocialPost.idempotency_key == idempotency_key, + ) + ) + return await self.get(workspace_id, post.id) if post else None + + async def create( + self, post: SocialPost, targets: list[SocialPostTarget] + ) -> tuple[SocialPost, list[SocialPostTarget]]: + try: + async with self.database.session(post.workspace_id) as session: + session.add(post) + await session.flush() + for target in targets: + target.social_post_id = post.id + session.add(target) + await session.commit() + await session.refresh(post) + for target in targets: + await session.refresh(target) + return post, targets + except IntegrityError: + if post.idempotency_key: + existing = await self.get_by_idempotency( + post.workspace_id, post.idempotency_key + ) + if existing and existing[0].request_fingerprint == post.request_fingerprint: + return existing + raise SocialIdempotencyConflictError( + "The idempotency key was already used for a different request." + ) + raise + + async def set_status( + self, workspace_id: str, post_id: str, status: str + ) -> tuple[SocialPost, list[SocialPostTarget]]: + async with self.database.session(workspace_id) as session: + post = await session.scalar( + select(SocialPost).where( + SocialPost.id == post_id, SocialPost.workspace_id == workspace_id + ) + ) + if post is None: + raise SocialPostNotFoundError("Social post was not found.") + post.status = status + if status == "published": + post.published_at = datetime.now(timezone.utc) + await session.commit() + return await self.get(workspace_id, post_id) + + async def set_target_status( + self, + workspace_id: str, + target_id: str, + status: str, + *, + error_code: str | None = None, + error_message: str | None = None, + external_post_id: str | None = None, + external_url: str | None = None, + provider_metadata: dict[str, object] | None = None, + ) -> SocialPostTarget: + async with self.database.session(workspace_id) as session: + target = await session.scalar( + select(SocialPostTarget) + .join(SocialPost, SocialPost.id == SocialPostTarget.social_post_id) + .where( + SocialPostTarget.id == target_id, + SocialPost.workspace_id == workspace_id, + ) + ) + if target is None: + raise SocialPostNotFoundError("Social post target was not found.") + target.status = status + target.error_code = error_code + target.error_message = error_message + target.external_post_id = external_post_id or target.external_post_id + target.external_url = external_url or target.external_url + if provider_metadata: + target.platform_metadata = { + **target.platform_metadata, + "provider": { + **( + target.platform_metadata.get("provider", {}) + if isinstance(target.platform_metadata.get("provider"), dict) + else {} + ), + **provider_metadata, + }, + } + if status == "published": + target.published_at = datetime.now(timezone.utc) + await session.commit() + return target + + async def delete(self, workspace_id: str, post_id: str) -> None: + post, _ = await self.get(workspace_id, post_id) + async with self.database.session(workspace_id) as session: + attached = await session.merge(post) + await session.delete(attached) + await session.commit() + + async def upsert_schedule( + self, + workspace_id: str, + post_id: str, + *, + scheduled_at: datetime, + timezone_name: str, + ) -> SocialSchedule: + await self.get(workspace_id, post_id) + try: + return await self._write_schedule( + workspace_id, post_id, scheduled_at, timezone_name + ) + except IntegrityError: + # Two clients can race to schedule the same post. The unique + # constraint remains authoritative; the loser retries as an update. + return await self._write_schedule( + workspace_id, post_id, scheduled_at, timezone_name + ) + + async def _write_schedule( + self, + workspace_id: str, + post_id: str, + scheduled_at: datetime, + timezone_name: str, + ) -> SocialSchedule: + async with self.database.session(workspace_id) as session: + schedule = await session.scalar( + select(SocialSchedule) + .where(SocialSchedule.social_post_id == post_id) + .with_for_update() + ) + if schedule is None: + schedule = SocialSchedule( + social_post_id=post_id, + scheduled_at=scheduled_at, + timezone=timezone_name, + status="scheduled", + ) + session.add(schedule) + else: + schedule.scheduled_at = scheduled_at + schedule.timezone = timezone_name + schedule.status = "scheduled" + post = await session.get(SocialPost, post_id) + if post: + post.status = "scheduled" + post.publish_mode = "schedule" + await session.commit() + await session.refresh(schedule) + return schedule + + async def cancel_schedule(self, workspace_id: str, post_id: str) -> None: + await self.get(workspace_id, post_id) + async with self.database.session(workspace_id) as session: + schedule = await session.scalar( + select(SocialSchedule).where(SocialSchedule.social_post_id == post_id) + ) + if schedule: + schedule.status = "cancelled" + await session.commit() + + async def claim_due_schedules(self, *, limit: int = 100) -> list[SocialSchedule]: + now = datetime.now(timezone.utc) + async with self.database.session() as session: + statement = ( + select(SocialSchedule) + .where( + SocialSchedule.status == "scheduled", + SocialSchedule.scheduled_at <= now, + ) + .order_by(SocialSchedule.scheduled_at) + .limit(limit) + .with_for_update(skip_locked=True) + ) + records = list((await session.scalars(statement)).all()) + for record in records: + record.status = "queued" + await session.commit() + return records diff --git a/app/social/repositories/tokens.py b/app/social/repositories/tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..381da586425e6d10cecf342717e8f8659ac6aaa1 --- /dev/null +++ b/app/social/repositories/tokens.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialAccountNotFoundError +from app.social.models import SocialAccount, SocialAccountToken + + +class TokenRepository: + """Token persistence is intentionally accessible only through TokenService.""" + + def __init__(self, database: SocialDatabase) -> None: + self.database = database + + async def get( + self, workspace_id: str, account_id: str + ) -> SocialAccountToken | None: + """Return a token record only within its owning tenant context.""" + async with self.database.session(workspace_id) as session: + return await session.scalar( + select(SocialAccountToken) + .join( + SocialAccount, + SocialAccount.id == SocialAccountToken.social_account_id, + ) + .where( + SocialAccountToken.social_account_id == account_id, + SocialAccount.workspace_id == workspace_id, + ) + ) + + async def save( + self, workspace_id: str, record: SocialAccountToken + ) -> SocialAccountToken: + async with self.database.session(workspace_id) as session: + owner = await session.scalar( + select(SocialAccount.id).where( + SocialAccount.id == record.social_account_id, + SocialAccount.workspace_id == workspace_id, + ) + ) + if owner is None: + raise SocialAccountNotFoundError("Social account was not found.") + existing = await session.scalar( + select(SocialAccountToken) + .join( + SocialAccount, + SocialAccount.id == SocialAccountToken.social_account_id, + ) + .where( + SocialAccountToken.social_account_id == record.social_account_id, + SocialAccount.workspace_id == workspace_id, + ) + ) + if existing: + existing.access_token_secret_id = record.access_token_secret_id + existing.refresh_token_secret_id = record.refresh_token_secret_id + existing.encrypted_payload = record.encrypted_payload + existing.expires_at = record.expires_at + existing.scopes = record.scopes + existing.token_type = record.token_type + existing.last_refreshed_at = datetime.now(timezone.utc) + existing.revoked_at = None + await session.commit() + return existing + session.add(record) + await session.commit() + await session.refresh(record) + return record + + async def revoke(self, workspace_id: str, account_id: str) -> None: + async with self.database.session(workspace_id) as session: + record = await session.scalar( + select(SocialAccountToken) + .join( + SocialAccount, + SocialAccount.id == SocialAccountToken.social_account_id, + ) + .where( + SocialAccountToken.social_account_id == account_id, + SocialAccount.workspace_id == workspace_id, + ) + ) + if record: + record.revoked_at = datetime.now(timezone.utc) + record.encrypted_payload = None + await session.commit() diff --git a/app/social/schemas/__init__.py b/app/social/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8924f3c8c202beff133d7d5d9ab934310def6ccd --- /dev/null +++ b/app/social/schemas/__init__.py @@ -0,0 +1,39 @@ +from app.social.schemas.accounts import ( + SocialAccountConnectRequest, + SocialAccountView, + SocialConnectResponse, + SocialProviderView, +) +from app.social.schemas.jobs import SocialJobView +from app.social.schemas.posts import ( + SocialPostCreate, + SocialPostTargetCreate, + SocialPostTargetView, + SocialPostView, +) +from app.social.schemas.scheduling import SocialScheduleCreate, SocialScheduleView +from app.social.schemas.tiktok import TikTokPostMetadata, TikTokPrivacyLevel +from app.social.schemas.youtube import ( + YouTubeCategoryId, + YouTubePostMetadata, + YouTubePrivacyStatus, +) + +__all__ = [ + "SocialAccountConnectRequest", + "SocialAccountView", + "SocialConnectResponse", + "SocialJobView", + "SocialPostCreate", + "SocialPostTargetCreate", + "SocialPostTargetView", + "SocialPostView", + "SocialProviderView", + "SocialScheduleCreate", + "SocialScheduleView", + "TikTokPostMetadata", + "TikTokPrivacyLevel", + "YouTubeCategoryId", + "YouTubePostMetadata", + "YouTubePrivacyStatus", +] diff --git a/app/social/schemas/accounts.py b/app/social/schemas/accounts.py new file mode 100644 index 0000000000000000000000000000000000000000..c637245ad31d9f521ad8873dbd195e7af05c9bb6 --- /dev/null +++ b/app/social/schemas/accounts.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, HttpUrl + +from app.social.domain.capabilities import ProviderCapabilities +from app.social.domain.enums import AccountStatus, ConnectionStrategy, Provider +from app.social.security import public_provider_data + + +class SocialProviderView(BaseModel): + provider: Provider + connection_strategy: ConnectionStrategy + capabilities: ProviderCapabilities + available: bool + configured: bool + + +class SocialAccountConnectRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + redirect_uri: HttpUrl | None = None + account_type: str | None = Field(default=None, max_length=64) + # Provider-product scopes are never silently added to a normal connection. + # Clients must explicitly choose analytics or publishing elevation. + authorization_purpose: Literal["connection", "publishing", "analytics"] = ( + "connection" + ) + + +class SocialConnectResponse(BaseModel): + provider: Provider + connection_strategy: ConnectionStrategy + authorization_url: str | None = None + state_expires_at: datetime | None = None + + +class SocialPublishOptionsView(BaseModel): + account_id: str + provider: Provider + options: dict[str, Any] = Field(default_factory=dict) + + +class SocialAccountView(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + provider: Provider + account_type: str + external_account_id: str + username: str | None = None + display_name: str | None = None + avatar_url: str | None = None + status: AccountStatus + metadata: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + last_synced_at: datetime | None = None + + @classmethod + def from_record(cls, record: object) -> "SocialAccountView": + data = { + key: value + for key, value in vars(record).items() + if not key.startswith("_") and key != "metadata_json" + } + data["metadata"] = public_provider_data(getattr(record, "metadata_json", {})) + return cls.model_validate(data) diff --git a/app/social/schemas/assets.py b/app/social/schemas/assets.py new file mode 100644 index 0000000000000000000000000000000000000000..456522249b1af921a3636e4176170ebc93a28df5 --- /dev/null +++ b/app/social/schemas/assets.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.social.security import public_provider_data + + +class SocialMediaAssetRegister(BaseModel): + """Reference a finished MediaRouter `/v1/media` output for publishing.""" + + model_config = ConfigDict(extra="forbid") + + request_id: UUID + filename: str = Field(min_length=1, max_length=255) + + +class SocialMediaAssetView(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + request_id: str + filename: str + mime_type: str + file_size: int + metadata: dict[str, Any] + created_at: datetime + + @classmethod + def from_record(cls, record: object) -> "SocialMediaAssetView": + return cls.model_validate( + { + "id": getattr(record, "id"), + "request_id": getattr(record, "request_id"), + "filename": getattr(record, "filename"), + "mime_type": getattr(record, "mime_type"), + "file_size": getattr(record, "file_size"), + "metadata": public_provider_data(getattr(record, "metadata_json", {})), + "created_at": getattr(record, "created_at"), + } + ) diff --git a/app/social/schemas/jobs.py b/app/social/schemas/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..b2e2fef95b835ad1aa7b0607c3990b17f9ec9dc0 --- /dev/null +++ b/app/social/schemas/jobs.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.social.domain.enums import JobStatus, Provider +from app.social.security import public_provider_data + + +class SocialJobView(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + social_post_id: str + social_post_target_id: str | None = None + provider: Provider | None = None + status: JobStatus + attempt_count: int + max_attempts: int + next_attempt_at: datetime | None = None + error_code: str | None = None + error_message: str | None = None + payload: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + updated_at: datetime + + @classmethod + def from_record(cls, record: object) -> "SocialJobView": + data = { + key: value + for key, value in vars(record).items() + if not key.startswith("_") and key != "payload_json" + } + data["payload"] = public_provider_data(getattr(record, "payload_json", {})) + return cls.model_validate(data) diff --git a/app/social/schemas/posts.py b/app/social/schemas/posts.py new file mode 100644 index 0000000000000000000000000000000000000000..6687b311f161865df8f34f2344188261bbcdc274 --- /dev/null +++ b/app/social/schemas/posts.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from app.social.domain.enums import PostStatus, Provider, PublishMode +from app.social.schemas.tiktok import TikTokPostMetadata +from app.social.schemas.youtube import YouTubePostMetadata + + +class PlatformCaption(BaseModel): + """Typed superset of provider-specific content; unknown fields fail closed.""" + + model_config = ConfigDict(extra="forbid") + + title: str | None = Field(default=None, max_length=500) + description: str | None = Field(default=None, max_length=10_000) + text: str | None = Field(default=None, max_length=10_000) + caption: str | None = Field(default=None, max_length=10_000) + commentary: str | None = Field(default=None, max_length=10_000) + tags: list[str] = Field(default_factory=list, max_length=100) + hashtags: list[str] = Field(default_factory=list, max_length=100) + + +class SocialPostTargetCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + social_account_id: str = Field(min_length=1, max_length=120) + caption: PlatformCaption = Field(default_factory=PlatformCaption) + youtube: YouTubePostMetadata | None = None + tiktok: TikTokPostMetadata | None = None + + @model_validator(mode="after") + def reject_caption_policy_overlap(self) -> "SocialPostTargetCreate": + # A title/description may be kept in the generic caption for clients + # that render a draft UI, but publishing policy and YouTube-specific + # values live only in the typed `youtube` object. + if self.youtube is not None and self.tiktok is not None: + raise ValueError("a social target may contain metadata for only one provider") + return self + + +class SocialPostCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + media_asset_id: str = Field(min_length=1, max_length=255) + targets: list[SocialPostTargetCreate] = Field(min_length=1, max_length=20) + publish_mode: PublishMode = PublishMode.DRAFT + campaign_id: str | None = Field(default=None, max_length=120) + source_variant_id: str | None = Field(default=None, max_length=120) + metadata: dict[str, Any] = Field(default_factory=dict) + scheduled_at: datetime | None = None + timezone: str | None = Field(default=None, max_length=100) + + @model_validator(mode="after") + def validate_schedule(self) -> "SocialPostCreate": + if self.publish_mode == PublishMode.SCHEDULE and ( + self.scheduled_at is None or not self.timezone + ): + raise ValueError("scheduled_at and timezone are required for scheduled posts") + if self.publish_mode == PublishMode.SCHEDULE: + assert self.scheduled_at is not None and self.timezone is not None + if self.scheduled_at.tzinfo is None or self.scheduled_at.utcoffset() is None: + raise ValueError("scheduled_at must include a UTC offset") + try: + ZoneInfo(self.timezone) + except ZoneInfoNotFoundError as exc: + raise ValueError("timezone must be a valid IANA timezone") from exc + if self.scheduled_at.astimezone(timezone.utc) <= datetime.now(timezone.utc): + raise ValueError("scheduled_at must be in the future") + if self.publish_mode != PublishMode.SCHEDULE and ( + self.scheduled_at is not None or self.timezone is not None + ): + raise ValueError("scheduled_at and timezone are only valid for scheduled posts") + account_ids = [target.social_account_id for target in self.targets] + if len(account_ids) != len(set(account_ids)): + raise ValueError("targets must not contain duplicate social accounts") + return self + + +class SocialPostTargetView(BaseModel): + id: str + social_account_id: str + provider: Provider + status: PostStatus + caption: dict[str, Any] + platform_metadata: dict[str, Any] + external_post_id: str | None = None + external_url: str | None = None + error_code: str | None = None + error_message: str | None = None + published_at: datetime | None = None + + +class SocialPostView(BaseModel): + id: str + media_asset_id: str + campaign_id: str | None = None + source_variant_id: str | None = None + status: PostStatus + publish_mode: PublishMode + metadata: dict[str, Any] + created_at: datetime + updated_at: datetime + published_at: datetime | None = None + targets: list[SocialPostTargetView] = Field(default_factory=list) diff --git a/app/social/schemas/scheduling.py b/app/social/schemas/scheduling.py new file mode 100644 index 0000000000000000000000000000000000000000..0914aa7605de83deab03b31c64ce794491f92b76 --- /dev/null +++ b/app/social/schemas/scheduling.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class SocialScheduleCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + + scheduled_at: datetime + timezone: str = Field(min_length=1, max_length=100) + + @field_validator("scheduled_at") + @classmethod + def timestamp_requires_timezone(cls, value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise ValueError("scheduled_at must include a UTC offset") + if value.astimezone(timezone.utc) <= datetime.now(timezone.utc): + raise ValueError("scheduled_at must be in the future") + return value + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except ZoneInfoNotFoundError as exc: + raise ValueError("timezone must be a valid IANA timezone") from exc + return value + + +class SocialScheduleView(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + social_post_id: str + scheduled_at: datetime + timezone: str + status: str + created_at: datetime + updated_at: datetime diff --git a/app/social/schemas/tiktok.py b/app/social/schemas/tiktok.py new file mode 100644 index 0000000000000000000000000000000000000000..797ea634cacf4fa408a3dad1b9343739b33c35d8 --- /dev/null +++ b/app/social/schemas/tiktok.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class TikTokPrivacyLevel(StrEnum): + PUBLIC_TO_EVERYONE = "PUBLIC_TO_EVERYONE" + MUTUAL_FOLLOW_FRIENDS = "MUTUAL_FOLLOW_FRIENDS" + FOLLOWER_OF_CREATOR = "FOLLOWER_OF_CREATOR" + SELF_ONLY = "SELF_ONLY" + + +class TikTokPostMetadata(BaseModel): + """Typed Direct Post declarations required by TikTok's current API.""" + + model_config = ConfigDict(extra="forbid") + + title: str = Field(default="", max_length=2200) + privacy_level: TikTokPrivacyLevel + disable_duet: bool = False + disable_stitch: bool = False + disable_comment: bool = False + video_cover_timestamp_ms: int | None = Field( + default=None, ge=0, le=600_000 + ) + # Explicit disclosure values avoid silently publishing branded or + # AI-generated content with a guessed policy state. + brand_content_toggle: bool + brand_organic_toggle: bool + is_aigc: bool + # TikTok's Direct Post UX requires an explicit Music Usage Confirmation. + # This field is MediaRouter policy evidence and is never sent to TikTok. + music_usage_confirmed: Literal[True] + + @field_validator("title") + @classmethod + def validate_utf16_title_length(cls, value: str) -> str: + # TikTok specifies 2,200 UTF-16 code units, which differs from + # Python/Pydantic's Unicode code-point length for astral characters. + if len(value.encode("utf-16-le")) // 2 > 2200: + raise ValueError("TikTok title must not exceed 2200 UTF-16 code units") + return value + + def to_post_info(self) -> dict[str, object]: + result: dict[str, object] = { + "privacy_level": self.privacy_level.value, + "title": self.title, + "disable_duet": self.disable_duet, + "disable_stitch": self.disable_stitch, + "disable_comment": self.disable_comment, + "brand_content_toggle": self.brand_content_toggle, + "brand_organic_toggle": self.brand_organic_toggle, + "is_aigc": self.is_aigc, + } + if self.video_cover_timestamp_ms is not None: + result["video_cover_timestamp_ms"] = self.video_cover_timestamp_ms + return result diff --git a/app/social/schemas/youtube.py b/app/social/schemas/youtube.py new file mode 100644 index 0000000000000000000000000000000000000000..38676ab826d90386a99f4f04872dbb24dc96b6ef --- /dev/null +++ b/app/social/schemas/youtube.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class YouTubePrivacyStatus(StrEnum): + PRIVATE = "private" + UNLISTED = "unlisted" + PUBLIC = "public" + + +class YouTubeCategoryId(StrEnum): + FILM_AND_ANIMATION = "1" + AUTOS_AND_VEHICLES = "2" + MUSIC = "10" + PETS_AND_ANIMALS = "15" + SPORTS = "17" + TRAVEL_AND_EVENTS = "19" + GAMING = "20" + PEOPLE_AND_BLOGS = "22" + COMEDY = "23" + ENTERTAINMENT = "24" + NEWS_AND_POLITICS = "25" + HOWTO_AND_STYLE = "26" + EDUCATION = "27" + SCIENCE_AND_TECHNOLOGY = "28" + NONPROFITS_AND_ACTIVISM = "29" + + +class YouTubePostMetadata(BaseModel): + """The deliberately small, policy-aware YouTube upload contract. + + Google accepts many snippet/status fields. Exposing only the fields + MediaRouter implements prevents clients from smuggling arbitrary provider + payloads into a durable publication job. + """ + + model_config = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=100) + description: str = Field(default="", max_length=5_000) + tags: list[str] = Field(default_factory=list, max_length=500) + category_id: YouTubeCategoryId | None = None + privacy_status: YouTubePrivacyStatus = YouTubePrivacyStatus.PRIVATE + made_for_kids: bool + notify_subscribers: bool = True + scheduled_publish_at: datetime | None = None + + @field_validator("tags") + @classmethod + def normalize_tags(cls, value: list[str]) -> list[str]: + normalized = [tag.strip() for tag in value] + if any(not tag for tag in normalized): + raise ValueError("YouTube tags must not be blank") + if any(len(tag) > 500 for tag in normalized): + raise ValueError("A YouTube tag may not exceed 500 characters") + if sum(len(tag) for tag in normalized) + max(0, len(normalized) - 1) > 500: + raise ValueError("YouTube tags may not exceed 500 characters in total") + return normalized + + @model_validator(mode="after") + def validate_scheduled_publish(self) -> "YouTubePostMetadata": + if self.scheduled_publish_at is None: + return self + if self.privacy_status is not YouTubePrivacyStatus.PRIVATE: + raise ValueError("YouTube scheduled_publish_at requires privacy_status=private") + if self.scheduled_publish_at.tzinfo is None or self.scheduled_publish_at.utcoffset() is None: + raise ValueError("YouTube scheduled_publish_at must include a UTC offset") + if self.scheduled_publish_at.astimezone(timezone.utc) <= datetime.now(timezone.utc): + raise ValueError("YouTube scheduled_publish_at must be in the future") + return self + + def to_youtube_resource(self) -> dict[str, object]: + status: dict[str, object] = { + "privacyStatus": self.privacy_status.value, + "selfDeclaredMadeForKids": self.made_for_kids, + } + if self.scheduled_publish_at is not None: + status["publishAt"] = self.scheduled_publish_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + snippet: dict[str, object] = { + "title": self.title, + "description": self.description, + "tags": self.tags, + } + if self.category_id is not None: + snippet["categoryId"] = self.category_id.value + return {"snippet": snippet, "status": status} diff --git a/app/social/security.py b/app/social/security.py new file mode 100644 index 0000000000000000000000000000000000000000..84fa33325f7eb3cee8f22e65e43924187fa3b453 --- /dev/null +++ b/app/social/security.py @@ -0,0 +1,71 @@ +"""Small, transport-facing safeguards for social provider data. + +Provider access tokens are credentials, not application data. This module is +intentionally used at every boundary that can serialize provider-controlled +metadata (REST, MCP, SDK, n8n, frontend, and audit records). +""" + +from __future__ import annotations + +import re +from typing import Any + + +_SENSITIVE_KEY_PARTS = frozenset( + { + "access_token", + "refresh_token", + "id_token", + "token", + "secret", + "authorization", + "cookie", + "password", + "api_key", + "credential", + } +) +_BEARER = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+") +_ASSIGNED_SECRET = re.compile( + r"(?i)(access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|" + r"authorization|api[_-]?key|password|secret|credential)" + r"([\"']?\s*[:=]\s*[\"']?)([^\"'\s,&}]+)" +) + + +def public_provider_data(value: Any) -> Any: + """Recursively omit credential-like fields from externally visible data. + + This is defense in depth. TokenService remains the only supported + credential reader, but provider responses and future metadata additions + must never be able to bypass that contract accidentally. + """ + + if isinstance(value, dict): + return { + str(key): public_provider_data(item) + for key, item in value.items() + if not _is_sensitive_key(str(key)) + } + if isinstance(value, list): + return [public_provider_data(item) for item in value] + if isinstance(value, tuple): + return [public_provider_data(item) for item in value] + if isinstance(value, str): + return redact_sensitive_text(value) + return value + + +def _is_sensitive_key(key: str) -> bool: + normalized = key.strip().lower().replace("-", "_") + return any(part in normalized for part in _SENSITIVE_KEY_PARTS) + + +def redact_sensitive_text(value: str) -> str: + """Remove recognizable credentials embedded in provider-controlled text.""" + + redacted = _BEARER.sub("Bearer [REDACTED]", value) + return _ASSIGNED_SECRET.sub( + lambda match: f"{match.group(1)}{match.group(2)}[REDACTED]", + redacted, + ) diff --git a/app/social/services/account_service.py b/app/social/services/account_service.py new file mode 100644 index 0000000000000000000000000000000000000000..04b18da50ffbf4a02997fd4721e76179839abb69 --- /dev/null +++ b/app/social/services/account_service.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy.exc import IntegrityError + +from app.core.logger import get_logger +from app.social.domain.enums import AccountStatus +from app.social.models import SocialAccount +from app.social.providers.registry import ProviderRegistry +from app.social.repositories.accounts import AccountRepository +from app.social.schemas.accounts import SocialAccountView, SocialProviderView +from app.social.services.token_service import TokenService + +logger = get_logger(__name__) + + +class AccountService: + def __init__( + self, + repository: AccountRepository, + tokens: TokenService, + providers: ProviderRegistry, + ) -> None: + self.repository = repository + self.tokens = tokens + self.providers = providers + + def list_providers(self) -> list[SocialProviderView]: + result: list[SocialProviderView] = [] + for adapter in self.providers.list(): + status = adapter.capabilities.implementation_status + configured = bool(getattr(adapter, "client_id", "")) + if status == "implemented": + configured = configured and bool(getattr(adapter, "_client_secret", "")) + configured = configured and bool( + getattr(adapter, "configuration_ready", True) + ) + result.append( + SocialProviderView( + provider=adapter.capabilities.provider, + connection_strategy=adapter.capabilities.connection_strategy, + capabilities=adapter.capabilities, + available=status == "implemented" and configured, + configured=configured, + ) + ) + return result + + def get_provider(self, provider: str) -> SocialProviderView: + adapter = self.providers.get(provider) + return next(item for item in self.list_providers() if item.provider == adapter.capabilities.provider) + + async def list( + self, workspace_id: str, *, offset: int = 0, limit: int = 100 + ) -> list[SocialAccountView]: + records = await self.repository.list(workspace_id, offset=offset, limit=limit) + return [SocialAccountView.from_record(record) for record in records] + + async def get(self, workspace_id: str, account_id: str) -> SocialAccountView: + return SocialAccountView.from_record(await self.repository.get(workspace_id, account_id)) + + async def create_connected( + self, + *, + workspace_id: str, + provider: str, + account_data: dict[str, object], + token: dict[str, object], + expires_at: datetime | None, + scopes: list[str], + token_type: str | None, + ) -> tuple[SocialAccountView, bool]: + external_account_id = str(account_data["external_account_id"]) + values = { + "account_type": str(account_data.get("account_type") or "user"), + "username": str(account_data["username"]) if account_data.get("username") else None, + "display_name": str(account_data["display_name"]) if account_data.get("display_name") else None, + "avatar_url": str(account_data["avatar_url"]) if account_data.get("avatar_url") else None, + "metadata": dict(account_data.get("metadata") or {}), + } + existing = await self.repository.get_by_external( + workspace_id, provider, external_account_id + ) + reauthorized = existing is not None + if existing is not None: + record = await self.repository.update_connection( + workspace_id, existing.id, **values + ) + else: + record = SocialAccount( + workspace_id=workspace_id, + provider=provider, + external_account_id=external_account_id, + status=AccountStatus.CONNECTED.value, + account_type=values["account_type"], + username=values["username"], + display_name=values["display_name"], + avatar_url=values["avatar_url"], + metadata_json=values["metadata"], + ) + try: + record = await self.repository.create(record) + except IntegrityError: + # The database unique constraint is authoritative when two + # callbacks for the same channel race. Reuse that account and + # replace credentials rather than surfacing a duplicate. + duplicate = await self.repository.get_by_external( + workspace_id, provider, external_account_id + ) + if duplicate is None: + raise + record = await self.repository.update_connection( + workspace_id, duplicate.id, **values + ) + reauthorized = True + try: + await self.tokens.store( + workspace_id, + record.id, + dict(token), + expires_at=expires_at, + scopes=scopes, + token_type=token_type, + ) + except Exception: + await self.repository.set_status(workspace_id, record.id, AccountStatus.ERROR.value) + raise + return SocialAccountView.from_record(record), reauthorized + + async def disconnect(self, workspace_id: str, account_id: str) -> None: + account = await self.repository.get(workspace_id, account_id) + try: + token = await self.tokens.retrieve( + workspace_id, account_id, allow_expired=True + ) + await self.providers.get(account.provider).revoke_token(token) + except Exception: + # Local revocation remains immediate during the provider-foundation phase. + logger.info( + "provider token revocation unavailable; local token revoked", + extra={"provider": account.provider, "social_account_id": account_id}, + ) + await self.tokens.revoke(workspace_id, account_id) + await self.repository.disconnect(workspace_id, account_id) diff --git a/app/social/services/analytics_service.py b/app/social/services/analytics_service.py new file mode 100644 index 0000000000000000000000000000000000000000..d4a651145e75b2e238f55c5f940e9cb0171039ab --- /dev/null +++ b/app/social/services/analytics_service.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialCapabilityUnsupportedError, SocialPostNotFoundError +from app.social.models import SocialPost, SocialPostMetric, SocialPostTarget +from app.social.providers.registry import ProviderRegistry +from app.social.repositories.accounts import AccountRepository +from app.social.security import public_provider_data +from app.social.services.oauth_service import OAuthService + + +class AnalyticsService: + """Normalizes only metrics returned by an authorized provider API.""" + + def __init__( + self, + database: SocialDatabase, + accounts: AccountRepository, + providers: ProviderRegistry, + oauth: OAuthService, + ) -> None: + self.database = database + self.accounts = accounts + self.providers = providers + self.oauth = oauth + + async def account(self, workspace_id: str, account_id: str) -> dict[str, object]: + account = await self.accounts.get(workspace_id, account_id) + adapter = self.providers.get(account.provider) + # YouTube Analytics API needs additional authorization beyond the + # upload scope. Do not request it implicitly or fabricate channel data. + if account.provider == "youtube": + return { + "account_id": account_id, + "metrics": [], + "status": "unavailable", + "reason": "YOUTUBE_CHANNEL_ANALYTICS_REQUIRES_EXPLICIT_ADDITIONAL_AUTHORIZATION", + } + if not adapter.capabilities.analytics: + raise SocialCapabilityUnsupportedError(f"{account.provider} analytics are unavailable.") + missing = await self._missing_analytics_scopes(workspace_id, account_id, adapter) + if missing: + return { + "account_id": account_id, + "metrics": [], + "status": "unavailable", + "reason": self._authorization_reason(account.provider), + "required_scopes": missing, + } + # social_post_metrics is intentionally post-target scoped. This API + # therefore reports account authorization readiness without inventing + # a Page/profile aggregate that cannot be represented in that model. + return { + "account_id": account_id, + "metrics": [], + "status": "unavailable", + "reason": "ACCOUNT_LEVEL_METRICS_NOT_MODELED", + } + + async def post(self, workspace_id: str, post_id: str) -> dict[str, object]: + async with self.database.session(workspace_id) as session: + post = await session.scalar( + select(SocialPost).where( + SocialPost.id == post_id, SocialPost.workspace_id == workspace_id + ) + ) + if post is None: + raise SocialPostNotFoundError("Social post was not found.") + targets = list( + ( + await session.scalars( + select(SocialPostTarget) + .join(SocialPost, SocialPost.id == SocialPostTarget.social_post_id) + .where( + SocialPostTarget.social_post_id == post_id, + SocialPost.workspace_id == workspace_id, + ) + ) + ).all() + ) + persisted: list[SocialPostMetric] = [] + unavailable: list[dict[str, object]] = [] + for target in targets: + if not target.external_post_id: + unavailable.append({"provider": target.provider, "status": "unavailable", "reason": "EXTERNAL_POST_ID_MISSING"}) + continue + account = await self.accounts.get(workspace_id, target.social_account_id) + adapter = self.providers.get(target.provider) + if not adapter.capabilities.analytics: + unavailable.append({"provider": target.provider, "status": "unavailable", "reason": "CAPABILITY_UNSUPPORTED"}) + continue + missing = await self._missing_analytics_scopes(workspace_id, account.id, adapter) + if missing: + unavailable.append( + { + "provider": target.provider, + "status": "unavailable", + "reason": self._authorization_reason(target.provider), + "required_scopes": missing, + } + ) + continue + analytics_external_id = self._analytics_external_id(target) + if analytics_external_id is None: + unavailable.append( + { + "provider": target.provider, + "status": "unavailable", + "reason": "TIKTOK_PUBLIC_VIDEO_ID_UNAVAILABLE", + } + ) + continue + metrics = await self.oauth.execute_with_reauth_retry( + workspace_id=workspace_id, + account_id=account.id, + operation=lambda token: adapter.get_metrics(token, analytics_external_id), + ) + if metrics.get("status") != "available": + unavailable.append( + { + "provider": target.provider, + "status": "unavailable", + "reason": str(metrics.get("reason") or "PROVIDER_UNAVAILABLE"), + } + ) + continue + record = SocialPostMetric( + social_post_id=post.id, + social_post_target_id=target.id, + provider=target.provider, + views=self._int(metrics.get("views")), + impressions=self._int(metrics.get("impressions")), + likes=self._int(metrics.get("likes")), + comments=self._int(metrics.get("comments")), + shares=self._int(metrics.get("shares")), + published_at=self._timestamp(metrics.get("published_at")), + raw_metrics=public_provider_data(dict(metrics.get("raw_metrics") or {})), + ) + async with self.database.session(workspace_id) as session: + session.add(record) + await session.commit() + await session.refresh(record) + persisted.append(record) + return { + "post_id": post_id, + "metrics": [self._view(item) for item in persisted], + "unavailable": unavailable, + } + + async def _missing_analytics_scopes( + self, workspace_id: str, account_id: str, adapter: object + ) -> list[str]: + """Require explicit scopes recorded by TokenService before Graph calls.""" + + capabilities = getattr(adapter, "capabilities") + required = set(capabilities.analytics_required_scopes) + if not required: + return [] + # Scope metadata is non-secret but still crosses the TokenService + # boundary so no consumer grows a parallel credential access path. + granted = await self.oauth.accounts.tokens.granted_scopes( + workspace_id, account_id + ) + return sorted(required - granted) + + @staticmethod + def _analytics_external_id(target: SocialPostTarget) -> str | None: + if target.provider != "tiktok": + return target.external_post_id + provider = ( + target.platform_metadata.get("provider") + if isinstance(target.platform_metadata, dict) + else None + ) + public_ids = provider.get("public_post_ids") if isinstance(provider, dict) else None + if not isinstance(public_ids, list): + return None + return next( + ( + str(value) + for value in public_ids + if isinstance(value, (str, int)) and not isinstance(value, bool) and str(value) + ), + None, + ) + + @staticmethod + def _authorization_reason(provider: str) -> str: + if provider in {"facebook", "instagram"}: + return "META_ANALYTICS_ADDITIONAL_AUTHORIZATION_REQUIRED" + return f"{provider.upper()}_ANALYTICS_ADDITIONAL_AUTHORIZATION_REQUIRED" + + @staticmethod + def _int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _timestamp(value: Any) -> datetime | None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + try: + return datetime.fromtimestamp(value, tz=timezone.utc) + except (OSError, OverflowError, ValueError): + return None + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + @staticmethod + def _view(item: SocialPostMetric) -> dict[str, object]: + return { + "provider": item.provider, + "views": item.views, + "impressions": item.impressions, + "likes": item.likes, + "comments": item.comments, + "shares": item.shares, + "engagement_rate": item.engagement_rate, + "published_at": item.published_at, + "retrieved_at": item.retrieved_at, + "raw_metrics": public_provider_data(item.raw_metrics), + } diff --git a/app/social/services/audit_service.py b/app/social/services/audit_service.py new file mode 100644 index 0000000000000000000000000000000000000000..ffefc38989d30e5637d6c5bd42fce01e2ab16467 --- /dev/null +++ b/app/social/services/audit_service.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Any + +from app.social.database import SocialDatabase +from app.social.models import SocialAuditEvent +from app.social.security import public_provider_data + + +class SocialAuditService: + def __init__(self, database: SocialDatabase) -> None: + self.database = database + + async def record( + self, + *, + workspace_id: str, + event_type: str, + api_key_id: str | None = None, + request_id: str | None = None, + provider: str | None = None, + social_account_id: str | None = None, + social_post_id: str | None = None, + social_job_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + async with self.database.session(workspace_id) as session: + session.add( + SocialAuditEvent( + workspace_id=workspace_id, + api_key_id=api_key_id, + event_type=event_type, + provider=provider, + social_account_id=social_account_id, + social_post_id=social_post_id, + social_job_id=social_job_id, + request_id=request_id, + metadata_json=public_provider_data(metadata or {}), + ) + ) + await session.commit() diff --git a/app/social/services/job_service.py b/app/social/services/job_service.py new file mode 100644 index 0000000000000000000000000000000000000000..4f7c91bf72d6b9e2bef952784150ecf304f0b73f --- /dev/null +++ b/app/social/services/job_service.py @@ -0,0 +1,13 @@ +from app.social.repositories.jobs import JobRepository +from app.social.schemas.jobs import SocialJobView + + +class JobService: + def __init__(self, repository: JobRepository) -> None: + self.repository = repository + + async def list(self, workspace_id: str, *, offset: int = 0, limit: int = 100) -> list[SocialJobView]: + return [SocialJobView.from_record(record) for record in await self.repository.list(workspace_id, offset=offset, limit=limit)] + + async def get(self, workspace_id: str, job_id: str) -> SocialJobView: + return SocialJobView.from_record(await self.repository.get(workspace_id, job_id)) diff --git a/app/social/services/media_asset_service.py b/app/social/services/media_asset_service.py new file mode 100644 index 0000000000000000000000000000000000000000..dfacd4d8a829eae2d37c64b25eb1d786be1a94c8 --- /dev/null +++ b/app/social/services/media_asset_service.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from app.core.exceptions import MediaAPIError +from app.services.cleanup import CleanupService +from app.services.ffprobe_service import FFprobeService +from app.services.validator import MediaValidator +from app.social.domain.errors import SocialMediaInvalidError +from app.social.models import SocialMediaAsset +from app.social.repositories.assets import SocialMediaAssetRepository +from app.social.schemas.assets import SocialMediaAssetRegister, SocialMediaAssetView + + +class SocialMediaAssetService: + """Binds existing MediaRouter outputs to a social workspace safely.""" + + def __init__( + self, + repository: SocialMediaAssetRepository, + cleanup: CleanupService, + ffprobe: FFprobeService, + validator: MediaValidator, + ) -> None: + self.repository = repository + self.cleanup = cleanup + self.ffprobe = ffprobe + self.validator = validator + + async def register( + self, workspace_id: str, payload: SocialMediaAssetRegister + ) -> SocialMediaAssetView: + request_id = str(payload.request_id) + path = self.cleanup.resolve_download(request_id, payload.filename) + if not path.is_file() or not os.access(path, os.R_OK): + raise SocialMediaInvalidError("Media asset is not readable.") + mime_type = self.validator.infer_mime(path) + if not mime_type.startswith("video/"): + raise SocialMediaInvalidError( + "Video social publishing requires a video media asset." + ) + try: + probe = await self.ffprobe.probe(path) + self.validator.validate_probe(probe) + except MediaAPIError as exc: + raise SocialMediaInvalidError("Media asset could not be validated for social publishing.") from exc + record = await self.repository.create( + SocialMediaAsset( + workspace_id=workspace_id, + request_id=request_id, + filename=path.name, + mime_type=mime_type, + file_size=path.stat().st_size, + metadata_json=probe, + ) + ) + return SocialMediaAssetView.from_record(record) + + async def list(self, workspace_id: str, *, offset: int = 0, limit: int = 100) -> list[SocialMediaAssetView]: + return [ + SocialMediaAssetView.from_record(record) + for record in await self.repository.list(workspace_id, offset=offset, limit=limit) + ] + + async def resolve_for_publish( + self, workspace_id: str, asset_id: str, *, source_variant_id: str | None = None + ) -> dict[str, Any]: + # Variants remain represented by the Phase 1 model. The owned source + # asset is authoritative until the existing template pipeline records + # a concrete variant asset reference. + asset = await self.repository.get(workspace_id, asset_id) + path = self.cleanup.resolve_download(asset.request_id, asset.filename) + if not path.is_file() or not os.access(path, os.R_OK): + raise SocialMediaInvalidError("Media asset is no longer readable; create a new MediaRouter output.") + actual_size = path.stat().st_size + if actual_size != asset.file_size: + raise SocialMediaInvalidError("Media asset changed after it was registered.") + try: + probe = await self.ffprobe.probe(path) + self.validator.validate_probe(probe) + except MediaAPIError as exc: + raise SocialMediaInvalidError("Media asset could not be validated for social publishing.") from exc + return { + "media_asset_id": asset.id, + "path": path, + "mime_type": asset.mime_type, + "filename": asset.filename, + "file_size": actual_size, + "probe": probe, + "source_variant_id": source_variant_id, + } + + async def assert_owned_and_readable(self, workspace_id: str, asset_id: str) -> None: + """Fast enqueue-time check; full FFprobe validation remains in worker.""" + asset = await self.repository.get(workspace_id, asset_id) + path = self.cleanup.resolve_download(asset.request_id, asset.filename) + if not path.is_file() or not os.access(path, os.R_OK): + raise SocialMediaInvalidError("Media asset is no longer readable; create a new MediaRouter output.") diff --git a/app/social/services/oauth_service.py b/app/social/services/oauth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..156ca1dcaa867fc1f09e68b1a1cfb9fda588c63a --- /dev/null +++ b/app/social/services/oauth_service.py @@ -0,0 +1,320 @@ +from __future__ import annotations + +import base64 +import hashlib +import re +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta, timezone +from typing import TypeVar +from urllib.parse import urlparse + +from app.core.config import Settings +from app.core.logger import get_logger +from app.social.domain.enums import AccountStatus, ConnectionStrategy +from app.social.domain.errors import ( + SocialPermissionDeniedError, + SocialProviderNotImplementedError, + SocialReauthRequiredError, +) +from app.social.oauth.state import OAuthStateService +from app.social.providers.registry import ProviderRegistry +from app.social.schemas.accounts import SocialAccountConnectRequest, SocialConnectResponse +from app.social.services.account_service import AccountService +from app.social.services.audit_service import SocialAuditService + +logger = get_logger(__name__) +_Result = TypeVar("_Result") + + +class OAuthService: + def __init__( + self, + settings: Settings, + providers: ProviderRegistry, + states: OAuthStateService, + accounts: AccountService, + audit: SocialAuditService, + ) -> None: + self.settings = settings + self.providers = providers + self.states = states + self.accounts = accounts + self.audit = audit + + def _redirect_uri(self, provider: str, requested: object | None) -> str: + # TikTok Login Kit stores a full redirect URI in its developer portal. + # Reusing this validation in the common OAuth service avoids a + # provider-specific callback path while permitting the required + # TIKTOK_REDIRECT_URI configuration. + if provider == "tiktok": + if not self.settings.tiktok_redirect_uri.strip(): + raise SocialPermissionDeniedError( + "TIKTOK_REDIRECT_URI is required for TikTok OAuth connections." + ) + # TikTok compares the token-exchange redirect URI with the value + # used for authorization. Preserve it byte-for-byte (apart from + # surrounding environment whitespace); a trailing slash is + # significant and is rejected below because it is not our route. + expected = self.settings.tiktok_redirect_uri.strip() + parsed = urlparse(expected) + local_hosts = {"localhost", "127.0.0.1", "::1"} + callback_path = "/v1/social/accounts/tiktok/callback" + if ( + not parsed.netloc + or parsed.query + or parsed.fragment + or parsed.path != callback_path + or (parsed.scheme != "https" and parsed.hostname not in local_hosts) + ): + raise SocialPermissionDeniedError( + "TIKTOK_REDIRECT_URI must be an HTTPS TikTok callback URI owned by this backend." + ) + if requested and str(requested) != expected: + raise SocialPermissionDeniedError( + "OAuth redirect URI does not match server configuration." + ) + return expected + configured = self.settings.social_oauth_redirect_base_url.strip().rstrip("/") + if not configured: + raise SocialPermissionDeniedError( + "SOCIAL_OAUTH_REDIRECT_BASE_URL is required for OAuth account connections." + ) + parsed = urlparse(configured) + local_hosts = {"localhost", "127.0.0.1", "::1"} + if not parsed.netloc or parsed.query or parsed.fragment or ( + parsed.scheme != "https" and parsed.hostname not in local_hosts + ): + raise SocialPermissionDeniedError( + "SOCIAL_OAUTH_REDIRECT_BASE_URL must be an HTTPS backend origin." + ) + expected = f"{configured}/v1/social/accounts/{provider}/callback" + if requested and str(requested).rstrip("/") != expected: + raise SocialPermissionDeniedError( + "OAuth redirect URI does not match server configuration." + ) + return expected + + async def connect( + self, + *, + provider: str, + workspace_id: str, + user_id: str, + payload: SocialAccountConnectRequest, + ) -> SocialConnectResponse: + adapter = self.providers.get(provider) + if adapter.capabilities.connection_strategy != ConnectionStrategy.OAUTH: + raise SocialProviderNotImplementedError( + f"{provider} uses {adapter.capabilities.connection_strategy.value}; its secure connection flow is not implemented yet." + ) + redirect_uri = self._redirect_uri(provider, payload.redirect_uri) + state = await self.states.create( + provider=adapter.provider, + workspace_id=workspace_id, + user_id=user_id, + redirect_uri=redirect_uri, + ) + verifier = ( + self.states.code_verifier(state) if adapter.pkce_supported else None + ) + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .rstrip(b"=") + .decode() + if verifier + else None + ) + if payload.authorization_purpose == "analytics": + additional_scopes = adapter.capabilities.analytics_required_scopes + elif payload.authorization_purpose == "publishing": + additional_scopes = adapter.capabilities.publishing_required_scopes + else: + additional_scopes = [] + authorization_url = await adapter.get_authorization_url( + state=state.state, + redirect_uri=redirect_uri, + code_challenge=challenge, + additional_scopes=additional_scopes, + ) + return SocialConnectResponse( + provider=adapter.capabilities.provider, + connection_strategy=adapter.capabilities.connection_strategy, + authorization_url=authorization_url, + state_expires_at=state.expires_at, + ) + + async def callback(self, *, provider: str, state: str, code: str) -> object: + adapter = self.providers.get(provider) + stored_state = await self.states.consume(state=state, provider=adapter.provider) + token = await adapter.exchange_code( + code=code, + redirect_uri=stored_state.redirect_uri, + code_verifier=( + self.states.code_verifier(stored_state) + if adapter.pkce_supported + else None + ), + ) + account = await adapter.get_account(token) + expires_in = token.get("expires_in") + expires_at = ( + datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) + if expires_in is not None + else None + ) + scopes = self._scopes(token.get("scope")) + connected, reauthorized = await self.accounts.create_connected( + workspace_id=stored_state.workspace_id, + provider=adapter.provider, + account_data=account, + token=token, + expires_at=expires_at, + scopes=scopes, + token_type=str(token.get("token_type")) if token.get("token_type") else None, + ) + await self.audit.record( + workspace_id=stored_state.workspace_id, + api_key_id=stored_state.user_id, + event_type="SOCIAL_ACCOUNT_REAUTHORIZED" if reauthorized else "SOCIAL_ACCOUNT_CONNECTED", + provider=adapter.provider, + social_account_id=connected.id, + ) + return connected + + async def callback_denied(self, *, provider: str, state: str) -> None: + """Consume a valid state even when Google returns an OAuth error.""" + adapter = self.providers.get(provider) + await self.states.consume(state=state, provider=adapter.provider) + raise SocialPermissionDeniedError("Provider authorization was denied or cancelled.") + + async def token_for_request(self, *, workspace_id: str, account_id: str) -> dict[str, object]: + """Retrieve, refresh once when needed, and return a provider token. + + TokenService remains the only token reader. The OAuth service owns + provider refresh orchestration so callers cannot accidentally bypass + expiration and reauthorization rules. + """ + account = await self.accounts.repository.get(workspace_id, account_id) + if account.status == AccountStatus.REAUTH_REQUIRED.value: + raise SocialReauthRequiredError("The social account requires reauthorization.") + authorization = await self.accounts.tokens.authorization_metadata( + workspace_id, account_id + ) + if authorization is None or authorization["revoked"]: + await self.accounts.repository.set_status( + workspace_id, account_id, AccountStatus.REAUTH_REQUIRED.value + ) + raise SocialReauthRequiredError("The social account requires reauthorization.") + expires_at = authorization["expires_at"] + now = datetime.now(timezone.utc) + if expires_at is not None: + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at <= now + timedelta(seconds=60): + try: + await self.refresh(workspace_id=workspace_id, account_id=account_id) + except Exception as exc: + await self.accounts.repository.set_status( + workspace_id, account_id, AccountStatus.REAUTH_REQUIRED.value + ) + if isinstance(exc, SocialReauthRequiredError): + raise + raise SocialReauthRequiredError( + "The social account requires reauthorization." + ) from exc + # `account` is intentionally fetched above for tenant validation even + # when there is no expiry timestamp. + del account + return await self.accounts.tokens.retrieve(workspace_id, account_id) + + async def execute_with_reauth_retry( + self, + *, + workspace_id: str, + account_id: str, + operation: Callable[[dict[str, object]], Awaitable[_Result]], + ) -> _Result: + """Execute a provider call with one bounded refresh/retry for a 401. + + Provider adapters are deliberately credential-store agnostic. Keeping + the retry here preserves TokenService as the only token reader and + ensures synchronous API paths cannot loop on an unauthorized token. + """ + token = await self.token_for_request( + workspace_id=workspace_id, account_id=account_id + ) + try: + return await operation(token) + except SocialReauthRequiredError: + try: + await self.refresh(workspace_id=workspace_id, account_id=account_id) + refreshed = await self.token_for_request( + workspace_id=workspace_id, account_id=account_id + ) + return await operation(refreshed) + except SocialReauthRequiredError: + await self.accounts.repository.set_status( + workspace_id, account_id, AccountStatus.REAUTH_REQUIRED.value + ) + raise + except Exception as exc: + await self.accounts.repository.set_status( + workspace_id, account_id, AccountStatus.REAUTH_REQUIRED.value + ) + raise SocialReauthRequiredError( + "The social account requires reauthorization." + ) from exc + + async def refresh(self, *, workspace_id: str, account_id: str) -> object: + account = await self.accounts.repository.get(workspace_id, account_id) + adapter = self.providers.get(account.provider) + current = await self.accounts.tokens.retrieve( + workspace_id, account_id, allow_expired=True + ) + try: + refreshed = await adapter.refresh_token(current) + except SocialReauthRequiredError: + await self.accounts.repository.set_status( + workspace_id, account_id, AccountStatus.REAUTH_REQUIRED.value + ) + raise + authorization = await self.accounts.tokens.authorization_metadata( + workspace_id, account_id + ) + expires_in = refreshed.get("expires_in") + expires_at = ( + datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) + if expires_in is not None + else authorization["expires_at"] if authorization else None + ) + scopes = ( + self._scopes(refreshed.get("scope")) + if refreshed.get("scope") + else list(authorization["scopes"]) if authorization else [] + ) + await self.accounts.tokens.store( + workspace_id, + account_id, + refreshed, + expires_at=expires_at, + scopes=scopes, + token_type=( + str(refreshed.get("token_type")) + if refreshed.get("token_type") + else authorization["token_type"] if authorization else None + ), + ) + await self.accounts.repository.set_status(workspace_id, account_id, "connected") + logger.info( + "social_token_refreshed", + extra={"workspace_id": workspace_id, "social_account_id": account_id, "provider": account.provider}, + ) + return await self.accounts.get(workspace_id, account_id) + + @staticmethod + def _scopes(value: object) -> list[str]: + """Normalize OAuth providers that return comma- or space-separated scopes.""" + + if not isinstance(value, str): + return [] + return [scope for scope in re.split(r"[\s,]+", value.strip()) if scope] diff --git a/app/social/services/publishing_service.py b/app/social/services/publishing_service.py new file mode 100644 index 0000000000000000000000000000000000000000..65eb85367f0155af2d367426b4fa6149fd4d2dc1 --- /dev/null +++ b/app/social/services/publishing_service.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import timezone + +from app.core.config import Settings +from app.social.domain.enums import AccountStatus, JobStatus, PostStatus, PublishMode +from app.social.domain.errors import ( + SocialAccountDisconnectedError, + SocialCapabilityUnsupportedError, + SocialIdempotencyConflictError, + SocialPermissionDeniedError, + SocialPublishFailedError, +) +from app.social.models import SocialJob, SocialPost, SocialPostTarget +from app.social.providers.registry import ProviderRegistry +from app.social.repositories.accounts import AccountRepository +from app.social.repositories.jobs import JobRepository +from app.social.repositories.posts import PostRepository +from app.social.schemas.jobs import SocialJobView +from app.social.schemas.accounts import SocialPublishOptionsView +from app.social.schemas.posts import SocialPostCreate, SocialPostTargetView, SocialPostView +from app.social.services.media_asset_service import SocialMediaAssetService +from app.social.services.oauth_service import OAuthService +from app.social.security import public_provider_data + + +def _fingerprint(payload: SocialPostCreate) -> str: + encoded = json.dumps( + payload.model_dump(mode="json"), sort_keys=True, separators=(",", ":") + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def post_view(post: SocialPost, targets: list[SocialPostTarget]) -> SocialPostView: + return SocialPostView( + id=post.id, + media_asset_id=post.media_asset_id, + campaign_id=post.campaign_id, + source_variant_id=post.source_variant_id, + status=post.status, + publish_mode=post.publish_mode, + metadata=public_provider_data(post.metadata_json), + created_at=post.created_at, + updated_at=post.updated_at, + published_at=post.published_at, + targets=[ + SocialPostTargetView( + id=target.id, + social_account_id=target.social_account_id, + provider=target.provider, + status=target.status, + caption=public_provider_data(target.caption_json), + platform_metadata=public_provider_data(target.platform_metadata), + external_post_id=target.external_post_id, + external_url=target.external_url, + error_code=target.error_code, + error_message=target.error_message, + published_at=target.published_at, + ) + for target in targets + ], + ) + + +class PublishingService: + def __init__( + self, + settings: Settings, + posts: PostRepository, + jobs: JobRepository, + accounts: AccountRepository, + providers: ProviderRegistry, + media_assets: SocialMediaAssetService, + oauth: OAuthService, + ) -> None: + self.settings = settings + self.posts = posts + self.jobs = jobs + self.accounts = accounts + self.providers = providers + self.media_assets = media_assets + self.oauth = oauth + + async def list( + self, workspace_id: str, *, offset: int = 0, limit: int = 100 + ) -> list[SocialPostView]: + return [ + post_view(post, targets) + for post, targets in await self.posts.list( + workspace_id, offset=offset, limit=limit + ) + ] + + async def get(self, workspace_id: str, post_id: str) -> SocialPostView: + return post_view(*(await self.posts.get(workspace_id, post_id))) + + async def create( + self, + *, + workspace_id: str, + user_id: str, + payload: SocialPostCreate, + idempotency_key: str | None, + ) -> SocialPostView: + fingerprint = _fingerprint(payload) + if payload.publish_mode == PublishMode.NOW and not idempotency_key: + raise SocialIdempotencyConflictError( + "Idempotency-Key is required when publish_mode is now." + ) + if idempotency_key: + existing = await self.posts.get_by_idempotency(workspace_id, idempotency_key) + if existing: + if existing[0].request_fingerprint != fingerprint: + raise SocialIdempotencyConflictError( + "The idempotency key was already used for a different request." + ) + return post_view(*existing) + + account_records = [] + await self.posts.assert_related_resources_owned( + workspace_id, + campaign_id=payload.campaign_id, + variant_id=payload.source_variant_id, + ) + for target in payload.targets: + account = await self.accounts.get(workspace_id, target.social_account_id) + if account.status != AccountStatus.CONNECTED.value: + raise SocialAccountDisconnectedError( + f"The {account.provider} account is not connected." + ) + self.providers.get(account.provider) + if account.provider == "youtube" and target.youtube is None: + raise SocialPublishFailedError( + "Typed YouTube metadata is required for a YouTube post." + ) + if account.provider != "youtube" and target.youtube is not None: + raise SocialPublishFailedError( + "YouTube metadata may only be used with a YouTube account." + ) + if account.provider == "tiktok" and target.tiktok is None: + raise SocialPublishFailedError( + "Typed TikTok metadata is required for a TikTok post." + ) + if account.provider != "tiktok" and target.tiktok is not None: + raise SocialPublishFailedError( + "TikTok metadata may only be used with a TikTok account." + ) + account_records.append(account) + if payload.publish_mode in {PublishMode.NOW, PublishMode.SCHEDULE}: + for account in account_records: + await self._assert_publish_authorized(workspace_id, account) + + initial = { + PublishMode.DRAFT: PostStatus.DRAFT, + PublishMode.NOW: PostStatus.DRAFT, + PublishMode.SCHEDULE: PostStatus.DRAFT, + }[payload.publish_mode] + post = SocialPost( + workspace_id=workspace_id, + campaign_id=payload.campaign_id, + media_asset_id=payload.media_asset_id, + source_variant_id=payload.source_variant_id, + status=initial.value, + publish_mode=payload.publish_mode.value, + idempotency_key=idempotency_key, + request_fingerprint=fingerprint, + metadata_json=payload.metadata, + created_by=user_id, + ) + targets = [ + SocialPostTarget( + social_post_id="", + social_account_id=target.social_account_id, + provider=account.provider, + status=PostStatus.DRAFT.value, + caption_json=target.caption.model_dump(exclude_none=True), + platform_metadata=( + {"youtube": target.youtube.model_dump(mode="json")} + if target.youtube is not None + else {"tiktok": target.tiktok.model_dump(mode="json")} + if target.tiktok is not None + else {} + ), + ) + for target, account in zip(payload.targets, account_records, strict=True) + ] + created = await self.posts.create(post, targets) + post, targets = created + if payload.publish_mode == PublishMode.SCHEDULE: + assert payload.scheduled_at is not None and payload.timezone is not None + await self.media_assets.assert_owned_and_readable(workspace_id, post.media_asset_id) + await self.posts.upsert_schedule( + workspace_id, + post.id, + scheduled_at=payload.scheduled_at.astimezone(timezone.utc), + timezone_name=payload.timezone, + ) + return await self.get(workspace_id, post.id) + if payload.publish_mode == PublishMode.NOW: + assert idempotency_key is not None + await self.queue(workspace_id, post.id, idempotency_key=idempotency_key) + return await self.get(workspace_id, post.id) + return post_view(*created) + + async def queue( + self, workspace_id: str, post_id: str, *, idempotency_key: str + ) -> list[SocialJobView]: + post, targets = await self.posts.get(workspace_id, post_id) + await self.media_assets.assert_owned_and_readable(workspace_id, post.media_asset_id) + jobs: list[SocialJob] = [] + candidates: list[SocialJob] = [] + for target in targets: + account = await self.accounts.get(workspace_id, target.social_account_id) + if account.status != AccountStatus.CONNECTED.value: + raise SocialAccountDisconnectedError( + f"The {account.provider} account is not connected." + ) + await self._assert_publish_authorized(workspace_id, account) + key = f"{idempotency_key}:{target.id}" + existing = await self.jobs.get_by_idempotency(workspace_id, key) + if existing: + jobs.append(existing) + continue + candidate = SocialJob( + workspace_id=workspace_id, + social_post_id=post.id, + social_post_target_id=target.id, + provider=target.provider, + status=JobStatus.QUEUED.value, + max_attempts=self.settings.social_publish_retry_limit, + idempotency_key=key, + payload_json={"media_asset_id": post.media_asset_id}, + ) + candidates.append(candidate) + if candidates: + jobs.extend(await self.jobs.create_many(candidates)) + await self.posts.set_status(workspace_id, post_id, PostStatus.QUEUED.value) + for target in targets: + if target.status == PostStatus.DRAFT.value: + await self.posts.set_target_status( + workspace_id, target.id, PostStatus.QUEUED.value + ) + return [SocialJobView.from_record(job) for job in jobs] + + async def validate_post_targets(self, workspace_id: str, post_id: str) -> None: + _, targets = await self.posts.get(workspace_id, post_id) + for target in targets: + account = await self.accounts.get(workspace_id, target.social_account_id) + if account.status != AccountStatus.CONNECTED.value: + raise SocialAccountDisconnectedError( + f"The {account.provider} account is not connected." + ) + await self._assert_publish_authorized(workspace_id, account) + + async def publish_options( + self, workspace_id: str, account_id: str + ) -> SocialPublishOptionsView: + account = await self.accounts.get(workspace_id, account_id) + await self._assert_publish_authorized(workspace_id, account) + adapter = self.providers.get(account.provider) + options = await self.oauth.execute_with_reauth_retry( + workspace_id=workspace_id, + account_id=account.id, + operation=adapter.get_publish_options, + ) + return SocialPublishOptionsView( + account_id=account.id, + provider=account.provider, + options=public_provider_data(options), + ) + + async def _assert_publish_authorized( + self, workspace_id: str, account: object + ) -> None: + provider = str(getattr(account, "provider")) + capabilities = self.providers.get(provider).capabilities + if not capabilities.publish_supported: + raise SocialCapabilityUnsupportedError( + f"{provider} publishing is unavailable for this application." + ) + required = set(capabilities.publishing_required_scopes) + if not required: + return + granted = await self.oauth.accounts.tokens.granted_scopes( + workspace_id, str(getattr(account, "id")) + ) + missing = sorted(required - granted) + if missing: + raise SocialPermissionDeniedError( + f"Reconnect the {provider} account and explicitly authorize publishing." + ) + + async def cancel(self, workspace_id: str, post_id: str) -> SocialPostView: + _, targets = await self.posts.get(workspace_id, post_id) + for job in await self.jobs.list_for_post(workspace_id, post_id): + if job.status not in {"published", "failed", "cancelled"}: + await self.jobs.transition(workspace_id, job.id, JobStatus.CANCELLED.value) + for target in targets: + if target.status not in {"published", "failed", "cancelled"}: + await self.posts.set_target_status( + workspace_id, target.id, PostStatus.CANCELLED.value + ) + await self.posts.cancel_schedule(workspace_id, post_id) + await self.posts.set_status(workspace_id, post_id, PostStatus.CANCELLED.value) + return await self.get(workspace_id, post_id) + + async def reconcile(self, workspace_id: str, post_id: str) -> SocialPostView: + post, targets = await self.posts.get(workspace_id, post_id) + states = {target.status for target in targets} + if states == {PostStatus.PUBLISHED.value}: + status = PostStatus.PUBLISHED + elif PostStatus.PUBLISHED.value in states and states <= { + PostStatus.PUBLISHED.value, + PostStatus.FAILED.value, + }: + status = PostStatus.PARTIAL_SUCCESS + elif states == {PostStatus.FAILED.value}: + status = PostStatus.FAILED + else: + return post_view(post, targets) + return post_view(*(await self.posts.set_status(workspace_id, post_id, status.value))) + + async def delete(self, workspace_id: str, post_id: str) -> None: + post = await self.get(workspace_id, post_id) + # Deleting a persisted external video must delete it at the provider + # first. The tenant-scoped post/target lookup prevents cross-workspace + # deletion and the provider token belongs to the target account. + for target in post.targets: + if not target.external_post_id: + continue + adapter = self.providers.get(target.provider) + if not adapter.capabilities.delete_post: + raise SocialCapabilityUnsupportedError( + f"{target.provider} does not support deleting published posts." + ) + await self.oauth.execute_with_reauth_retry( + workspace_id=workspace_id, + account_id=target.social_account_id, + operation=lambda token: adapter.delete_post(token, target.external_post_id), + ) + await self.posts.delete(workspace_id, post_id) diff --git a/app/social/services/scheduling_service.py b/app/social/services/scheduling_service.py new file mode 100644 index 0000000000000000000000000000000000000000..b21f189a64938d18afc27c40380021b5cf545a80 --- /dev/null +++ b/app/social/services/scheduling_service.py @@ -0,0 +1,22 @@ +from datetime import timezone + +from app.social.repositories.posts import PostRepository +from app.social.schemas.scheduling import SocialScheduleCreate, SocialScheduleView +from app.social.services.media_asset_service import SocialMediaAssetService + + +class SchedulingService: + def __init__(self, posts: PostRepository, media_assets: SocialMediaAssetService) -> None: + self.posts = posts + self.media_assets = media_assets + + async def schedule(self, workspace_id: str, post_id: str, payload: SocialScheduleCreate) -> SocialScheduleView: + post, _ = await self.posts.get(workspace_id, post_id) + await self.media_assets.assert_owned_and_readable(workspace_id, post.media_asset_id) + schedule = await self.posts.upsert_schedule( + workspace_id, + post_id, + scheduled_at=payload.scheduled_at.astimezone(timezone.utc), + timezone_name=payload.timezone, + ) + return SocialScheduleView.model_validate(schedule) diff --git a/app/social/services/social_service.py b/app/social/services/social_service.py new file mode 100644 index 0000000000000000000000000000000000000000..92413f9324a56aa7c06ccf3fad3716a553a3fb6c --- /dev/null +++ b/app/social/services/social_service.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from app.core.config import Settings +from app.core.logger import get_logger +from app.social.database import SocialDatabase +from app.social.domain.errors import SocialProviderUnavailableError +from app.social.services.account_service import AccountService +from app.social.services.analytics_service import AnalyticsService +from app.social.services.audit_service import SocialAuditService +from app.social.services.job_service import JobService +from app.social.services.media_asset_service import SocialMediaAssetService +from app.social.services.oauth_service import OAuthService +from app.social.services.publishing_service import PublishingService +from app.social.services.scheduling_service import SchedulingService + +logger = get_logger(__name__) + + +class SocialService: + """Transport-neutral facade shared by REST, MCP, workers, and SDK contracts.""" + + def __init__( + self, + *, + settings: Settings, + database: SocialDatabase, + accounts: AccountService, + oauth: OAuthService, + publishing: PublishingService, + scheduling: SchedulingService, + jobs: JobService, + media_assets: SocialMediaAssetService, + analytics: AnalyticsService, + audit: SocialAuditService, + ) -> None: + self.settings = settings + self.database = database + self.accounts = accounts + self.oauth = oauth + self.publishing = publishing + self.scheduling = scheduling + self.jobs = jobs + self.media_assets = media_assets + self.analytics = analytics + self.audit = audit + self.ready = False + + async def initialize(self) -> None: + if not self.settings.social_enabled: + logger.info("social automation disabled") + return + try: + await self.database.initialize() + self.ready = await self.database.schema_ready() + except Exception: + self.ready = False + logger.exception("social database initialization check failed") + return + if not self.ready: + missing_tables = await self.database.missing_tables() + logger.warning( + "social schema unavailable; apply all social migrations or enable SOCIAL_AUTO_MIGRATE for local development", + extra={"missing_social_tables": missing_tables}, + ) + else: + logger.info("social automation initialized") + + def ensure_ready(self) -> None: + if not self.settings.social_enabled: + raise SocialProviderUnavailableError("Social automation is disabled.") + if not self.ready: + raise SocialProviderUnavailableError( + "Social database schema is unavailable. Apply the social migration." + ) + + async def close(self) -> None: + await self.accounts.providers.close() + await self.database.close() diff --git a/app/social/services/token_service.py b/app/social/services/token_service.py new file mode 100644 index 0000000000000000000000000000000000000000..416f4915a041400ca76407ada38f90aab4fec4b9 --- /dev/null +++ b/app/social/services/token_service.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import text + +from app.core.config import Settings +from app.social.domain.errors import SocialProviderUnavailableError, SocialReauthRequiredError +from app.social.models import SocialAccountToken +from app.social.oauth.encryption import TokenCipher +from app.social.repositories.tokens import TokenRepository + + +class TokenService: + """Only component allowed to retrieve provider credentials.""" + + def __init__(self, repository: TokenRepository, settings: Settings, cipher: TokenCipher) -> None: + self.repository = repository + self.settings = settings + self.cipher = cipher + + async def _vault_store(self, value: str, name: str) -> str: + async with self.repository.database.session() as session: + secret_id = await session.scalar( + text("select vault.create_secret(:secret, :name, :description)"), + {"secret": value, "name": name, "description": "MediaRouter social credential"}, + ) + await session.commit() + return str(secret_id) + + async def _vault_get(self, secret_id: str) -> str | None: + async with self.repository.database.session() as session: + value = await session.scalar( + text("select decrypted_secret from vault.decrypted_secrets where id = cast(:id as uuid)"), + {"id": secret_id}, + ) + return str(value) if value is not None else None + + async def _vault_delete(self, secret_id: str) -> None: + async with self.repository.database.session() as session: + await session.execute(text("delete from vault.secrets where id = cast(:id as uuid)"), {"id": secret_id}) + await session.commit() + + async def store( + self, + workspace_id: str, + account_id: str, + token: dict[str, Any], + *, + expires_at: datetime | None = None, + scopes: list[str] | None = None, + token_type: str | None = None, + ) -> None: + previous = await self.repository.get(workspace_id, account_id) + merged = dict(token) + if previous is not None and not merged.get("refresh_token"): + try: + current = await self.retrieve( + workspace_id, account_id, allow_expired=True + ) + if current.get("refresh_token"): + merged["refresh_token"] = current["refresh_token"] + except SocialReauthRequiredError: + pass + if self.settings.supabase_vault_enabled: + if not self.repository.database.database_url.startswith(("postgresql", "postgres")): + raise SocialProviderUnavailableError("Supabase Vault requires a Postgres SOCIAL_DATABASE_URL.") + access = str(merged.get("access_token", "")) + if not access: + raise SocialProviderUnavailableError("The provider did not return an access token.") + access_id = await self._vault_store(access, f"social:{account_id}:access") + try: + refresh_id = await self._vault_store(str(merged["refresh_token"]), f"social:{account_id}:refresh") if merged.get("refresh_token") else None + except Exception: + await self._vault_delete(access_id) + raise + payload = None + else: + access_id = refresh_id = None + payload = self.cipher.encrypt(merged) + try: + await self.repository.save( + workspace_id, + SocialAccountToken( + social_account_id=account_id, + access_token_secret_id=access_id, + refresh_token_secret_id=refresh_id, + encrypted_payload=payload, + expires_at=expires_at, + scopes=scopes or [], + token_type=token_type, + ), + ) + except Exception: + if self.settings.supabase_vault_enabled: + for secret_id in (access_id, refresh_id): + if secret_id: + await self._vault_delete(secret_id) + raise + if self.settings.supabase_vault_enabled and previous: + for old, new in ( + (previous.access_token_secret_id, access_id), + (previous.refresh_token_secret_id, refresh_id), + ): + if old and old != new: + await self._vault_delete(old) + + async def retrieve( + self, + workspace_id: str, + account_id: str, + *, + allow_expired: bool = False, + ) -> dict[str, Any]: + record = await self.repository.get(workspace_id, account_id) + if record is None or record.revoked_at is not None: + raise SocialReauthRequiredError("The social account requires reauthorization.") + if record.expires_at is not None and not allow_expired: + expires_at = record.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at <= datetime.now(timezone.utc): + raise SocialReauthRequiredError("The social account token has expired.") + if record.encrypted_payload: + return self.cipher.decrypt(record.encrypted_payload) + if record.access_token_secret_id: + access = await self._vault_get(record.access_token_secret_id) + if access is None: + raise SocialProviderUnavailableError("The configured Vault adapter could not retrieve this token.") + result: dict[str, Any] = {"access_token": access} + if record.refresh_token_secret_id: + refresh = await self._vault_get(record.refresh_token_secret_id) + if refresh is not None: + result["refresh_token"] = refresh + return result + raise SocialReauthRequiredError("The social account requires reauthorization.") + + async def granted_scopes(self, workspace_id: str, account_id: str) -> set[str]: + """Return non-secret authorization metadata through the credential boundary.""" + record = await self.repository.get(workspace_id, account_id) + if record is None or record.revoked_at is not None: + raise SocialReauthRequiredError( + "The social account requires reauthorization." + ) + return set(record.scopes) + + async def authorization_metadata( + self, workspace_id: str, account_id: str + ) -> dict[str, Any] | None: + """Return only non-secret token lifecycle metadata. + + OAuth orchestration needs expiry, scope, and token-type information, + but must not reach into TokenRepository or receive secret identifiers. + Keeping this method on TokenService preserves one audited credential + boundary for both secret and non-secret authorization state. + """ + + record = await self.repository.get(workspace_id, account_id) + if record is None: + return None + return { + "expires_at": record.expires_at, + "scopes": list(record.scopes), + "token_type": record.token_type, + "revoked": record.revoked_at is not None, + } + + async def revoke(self, workspace_id: str, account_id: str) -> None: + record = await self.repository.get(workspace_id, account_id) + if record: + for secret_id in (record.access_token_secret_id, record.refresh_token_secret_id): + if secret_id: + await self._vault_delete(secret_id) + await self.repository.revoke(workspace_id, account_id) diff --git a/app/social/webhooks/__init__.py b/app/social/webhooks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6bf9bea2ef545d315836acce819bf174159f55fb --- /dev/null +++ b/app/social/webhooks/__init__.py @@ -0,0 +1,4 @@ +from app.social.webhooks.base import NormalizedWebhookEvent, SocialWebhookAdapter +from app.social.webhooks.registry import WebhookRegistry + +__all__ = ["NormalizedWebhookEvent", "SocialWebhookAdapter", "WebhookRegistry"] diff --git a/app/social/webhooks/base.py b/app/social/webhooks/base.py new file mode 100644 index 0000000000000000000000000000000000000000..f5afa2ddb837b619cd48ae30ee8619d0aa54c805 --- /dev/null +++ b/app/social/webhooks/base.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class NormalizedWebhookEvent(BaseModel): + model_config = ConfigDict(extra="forbid") + + provider: str + event_type: str + external_event_id: str + workspace_id: str | None = None + payload: dict[str, Any] + received_at: datetime + + +class SocialWebhookAdapter(ABC): + provider: str + + @abstractmethod + async def verify_and_normalize( + self, *, headers: dict[str, str], body: bytes + ) -> list[NormalizedWebhookEvent]: + """Verify the provider signature before returning normalized events.""" diff --git a/app/social/webhooks/registry.py b/app/social/webhooks/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..dc2a799b7ab9050415c45ec1d8fc17424bcc5d4a --- /dev/null +++ b/app/social/webhooks/registry.py @@ -0,0 +1,15 @@ +from app.social.domain.errors import SocialProviderUnavailableError +from app.social.webhooks.base import SocialWebhookAdapter + + +class WebhookRegistry: + def __init__(self, adapters: list[SocialWebhookAdapter] | None = None) -> None: + self._adapters = {adapter.provider: adapter for adapter in adapters or []} + + def get(self, provider: str) -> SocialWebhookAdapter: + try: + return self._adapters[provider.strip().lower()] + except KeyError as exc: + raise SocialProviderUnavailableError( + f"Webhook processing is not configured for '{provider}'." + ) from exc diff --git a/app/social/workers/__init__.py b/app/social/workers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e07465ad85ea1e706fe2cb01b7ee77949d1afb91 --- /dev/null +++ b/app/social/workers/__init__.py @@ -0,0 +1,4 @@ +from app.social.workers.publisher import SocialPublisher +from app.social.workers.scheduler import SocialSchedulerWorker + +__all__ = ["SocialPublisher", "SocialSchedulerWorker"] diff --git a/app/social/workers/publisher.py b/app/social/workers/publisher.py new file mode 100644 index 0000000000000000000000000000000000000000..1f73bb86de7485010175147b4d19980059070745 --- /dev/null +++ b/app/social/workers/publisher.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import random +from datetime import datetime, timedelta, timezone + +from app.core.exceptions import MediaAPIError +from app.core.logger import get_logger +from app.social.domain.enums import JobStatus, PostStatus +from app.social.domain.errors import SocialPublishFailedError +from app.social.domain.retry import classify_retry +from app.social.models import SocialJob, SocialPost, SocialPostTarget +from app.social.schemas.tiktok import TikTokPostMetadata +from app.social.schemas.youtube import YouTubePostMetadata +from app.social.services.social_service import SocialService + +logger = get_logger(__name__) + + +class SocialPublisher: + """Durable provider worker; HTTP handlers only enqueue a SocialJob.""" + + def __init__(self, social: SocialService) -> None: + self.social = social + + async def tick(self) -> None: + if not self.social.ready: + return + for job in await self.social.jobs.repository.claim_due( + stale_after_seconds=self.social.settings.social_job_stale_after_seconds + ): + await self.process(job.workspace_id, job.id) + + async def process(self, workspace_id: str, job_id: str) -> None: + jobs = self.social.jobs.repository + job = await jobs.get(workspace_id, job_id) + if job.status == JobStatus.PUBLISHING.value: + # A completed upload may remain server-side processing for minutes. + # Polling is not a retry and never increments attempt_count. + await self._reconcile_external(workspace_id, job, attempt_id=None) + return + if job.status in {JobStatus.RETRYING.value, JobStatus.QUEUED.value}: + job = await jobs.transition(workspace_id, job.id, JobStatus.PREPARING.value) + elif job.status != JobStatus.PREPARING.value: + return + attempt = await jobs.start_attempt(workspace_id, job.id) + job = await jobs.get(workspace_id, job.id) + try: + post, target, account, adapter = await self._context(workspace_id, job) + await self.social.publishing.posts.set_target_status( + workspace_id, target.id, PostStatus.PREPARING.value + ) + job = await jobs.transition(workspace_id, job.id, JobStatus.PROCESSING.value) + + # An external ID means the provider accepted a previous upload. + # Reconcile it instead of creating a duplicate after a crash. + if target.external_post_id: + # Upload recovery data is no longer needed after the external + # identity is durable, and bearer-like upload URLs should not + # be retained beyond that point. + await jobs.set_provider_state(workspace_id, job.id, None) + job = await jobs.transition(workspace_id, job.id, JobStatus.UPLOADING.value) + job = await jobs.transition(workspace_id, job.id, JobStatus.PUBLISHING.value) + await self._reconcile_external(workspace_id, job, attempt_id=attempt.id) + return + + media = await self.social.media_assets.resolve_for_publish( + workspace_id, post.media_asset_id, source_variant_id=post.source_variant_id + ) + await adapter.validate_media(media) + job = await jobs.transition(workspace_id, job.id, JobStatus.UPLOADING.value) + await self.social.publishing.posts.set_target_status( + workspace_id, target.id, PostStatus.UPLOADING.value + ) + youtube_data = target.platform_metadata.get("youtube") + if target.provider == "youtube" and not isinstance(youtube_data, dict): + raise SocialPublishFailedError( + "Typed YouTube metadata is missing from this target." + ) + youtube = ( + YouTubePostMetadata.model_validate(youtube_data) + if target.provider == "youtube" and youtube_data + else None + ) + tiktok_data = target.platform_metadata.get("tiktok") + if target.provider == "tiktok" and not isinstance(tiktok_data, dict): + raise SocialPublishFailedError( + "Typed TikTok metadata is missing from this target." + ) + tiktok = ( + TikTokPostMetadata.model_validate(tiktok_data) + if target.provider == "tiktok" and tiktok_data + else None + ) + state = await jobs.get_provider_state(workspace_id, job.id) + + async def persist_upload_session(value: str | None) -> None: + # Resumable session URLs are bearer-like. JobRepository encrypts + # them and SocialJobView intentionally never exposes the field. + await jobs.set_provider_state( + workspace_id, + job.id, + {"youtube_upload_session_url": value} if value else None, + ) + + async def persist_provider_state( + value: dict[str, object] | None, + ) -> None: + # TikTok upload URLs and publish recovery state are bearer-like + # and remain encrypted in SocialJob.provider_state_encrypted. + await jobs.set_provider_state(workspace_id, job.id, value) + + media["youtube_resource"] = youtube.to_youtube_resource() if youtube else None + media["notify_subscribers"] = youtube.notify_subscribers if youtube else True + media["upload_session_url"] = state.get("youtube_upload_session_url") + media["persist_upload_session"] = persist_upload_session + media["tiktok_post_info"] = ( + tiktok.to_post_info() if tiktok is not None else None + ) + media["provider_state"] = state + media["persist_provider_state"] = persist_provider_state + media["heartbeat"] = lambda: jobs.heartbeat(workspace_id, job.id) + uploaded = await self.social.oauth.execute_with_reauth_retry( + workspace_id=workspace_id, + account_id=account.id, + operation=lambda request_token: adapter.upload_media(request_token, media), + ) + external_id = uploaded.get("id") + if not isinstance(external_id, str) or not external_id: + raise SocialPublishFailedError( + f"{target.provider} upload did not return a durable external ID." + ) + # Persist the external identity before status reconciliation. If a + # worker crashes below this line, all later executions reconcile + # this exact video instead of retrying the insert request. + await self.social.publishing.posts.set_target_status( + workspace_id, + target.id, + PostStatus.UPLOADING.value, + external_post_id=external_id, + external_url=str(uploaded.get("url")) if uploaded.get("url") else None, + provider_metadata={ + "upload_completed": True, + **( + uploaded.get("metadata", {}) + if isinstance(uploaded.get("metadata"), dict) + else {} + ), + }, + ) + await jobs.set_provider_state(workspace_id, job.id, None) + job = await jobs.transition(workspace_id, job.id, JobStatus.PUBLISHING.value) + await self.social.publishing.posts.set_target_status( + workspace_id, target.id, PostStatus.PUBLISHING.value + ) + await self.social.audit.record( + workspace_id=workspace_id, + event_type="SOCIAL_POST_PUBLISH_STARTED", + provider=target.provider, + social_account_id=account.id, + social_post_id=post.id, + social_job_id=job.id, + ) + await self.social.oauth.execute_with_reauth_retry( + workspace_id=workspace_id, + account_id=account.id, + operation=lambda request_token: adapter.publish( + request_token, + {"upload": uploaded, "idempotency_key": job.idempotency_key}, + ), + ) + await self._reconcile_external(workspace_id, job, attempt_id=attempt.id) + except Exception as exc: + await self._handle_failure(workspace_id, job, attempt.id, exc) + finally: + await self.social.publishing.reconcile(workspace_id, job.social_post_id) + + async def _context( + self, workspace_id: str, job: SocialJob + ) -> tuple[SocialPost, SocialPostTarget, object, object]: + post, targets = await self.social.publishing.posts.get(workspace_id, job.social_post_id) + target = next(item for item in targets if item.id == job.social_post_target_id) + account = await self.social.accounts.repository.get(workspace_id, target.social_account_id) + adapter = self.social.accounts.providers.get(account.provider) + return post, target, account, adapter + + async def _reconcile_external( + self, workspace_id: str, job: SocialJob, *, attempt_id: str | None + ) -> None: + try: + post, target, account, adapter = await self._context(workspace_id, job) + if not target.external_post_id: + raise SocialPublishFailedError( + "The provider upload has no durable external ID to reconcile." + ) + result = await self.social.oauth.execute_with_reauth_retry( + workspace_id=workspace_id, + account_id=account.id, + operation=lambda request_token: adapter.get_publish_status( + request_token, target.external_post_id + ), + ) + normalized = result.get("status") + metadata = result.get("metadata") if isinstance(result.get("metadata"), dict) else {} + if normalized == "published": + await self.social.publishing.posts.set_target_status( + workspace_id, + target.id, + PostStatus.PUBLISHED.value, + external_post_id=str(result.get("id") or target.external_post_id), + external_url=str(result.get("url")) if result.get("url") else None, + provider_metadata=metadata, + ) + await self.social.jobs.repository.transition( + workspace_id, job.id, JobStatus.PUBLISHED.value + ) + if attempt_id: + await self.social.jobs.repository.complete_attempt( + attempt_id, + status="published", + provider_request_id=str(result.get("id") or target.external_post_id), + ) + await self.social.audit.record( + workspace_id=workspace_id, + event_type="SOCIAL_POST_PUBLISHED", + provider=target.provider, + social_account_id=account.id, + social_post_id=post.id, + social_job_id=job.id, + metadata={"external_post_id": target.external_post_id}, + ) + logger.info( + "social_publish_completed", + extra={ + "workspace_id": workspace_id, + "provider": target.provider, + "social_account_id": account.id, + "social_post_id": post.id, + "target_id": target.id, + "job_id": job.id, + "external_post_id": target.external_post_id, + }, + ) + return + if normalized == "processing": + await self.social.publishing.posts.set_target_status( + workspace_id, target.id, PostStatus.PROCESSING.value, provider_metadata=metadata + ) + await self.social.jobs.repository.defer_reconciliation( + workspace_id, + job.id, + next_attempt_at=datetime.now(timezone.utc) + + timedelta( + seconds=max( + 5, + int( + getattr( + adapter, + "reconciliation_poll_seconds", + 30, + ) + ), + ) + ), + ) + if attempt_id: + await self.social.jobs.repository.complete_attempt( + attempt_id, status="processing", provider_request_id=target.external_post_id + ) + return + raise adapter.publish_failure(result) + except Exception as exc: + await self._handle_failure(workspace_id, job, attempt_id, exc) + finally: + await self.social.publishing.reconcile(workspace_id, job.social_post_id) + + async def _handle_failure( + self, workspace_id: str, job: SocialJob, attempt_id: str | None, exc: Exception + ) -> None: + jobs = self.social.jobs.repository + status_code = exc.status_code if isinstance(exc, MediaAPIError) else None + decision = classify_retry( + status_code=status_code, + network_error=not isinstance(exc, MediaAPIError), + attempt=max(job.attempt_count, 1), + ) + code = getattr(exc, "code", "SOCIAL_PUBLISH_FAILED") + safe_message = str(exc) if isinstance(exc, MediaAPIError) else "A temporary provider error occurred." + if attempt_id: + await jobs.complete_attempt( + attempt_id, status="failed", error_code=code, error_message=safe_message + ) + refresh_succeeded = True + if decision.refresh_token_first: + account_id: str | None = None + try: + _, targets = await self.social.publishing.posts.get(workspace_id, job.social_post_id) + target = next(item for item in targets if item.id == job.social_post_target_id) + account_id = target.social_account_id + await self.social.oauth.refresh(workspace_id=workspace_id, account_id=account_id) + except Exception: + refresh_succeeded = False + if account_id: + await self.social.accounts.repository.set_status( + workspace_id, account_id, "reauth_required" + ) + can_retry = decision.retryable and refresh_succeeded and job.attempt_count < job.max_attempts + if can_retry: + jitter = random.uniform(0, max(1, decision.delay_seconds * 0.2)) + next_attempt = datetime.now(timezone.utc) + timedelta( + seconds=decision.delay_seconds + jitter + ) + await jobs.transition( + workspace_id, + job.id, + JobStatus.RETRYING.value, + error_code=code, + error_message=safe_message, + next_attempt_at=next_attempt, + ) + if job.social_post_target_id: + await self.social.publishing.posts.set_target_status( + workspace_id, + job.social_post_target_id, + PostStatus.RETRYING.value, + error_code=code, + error_message=safe_message, + ) + return + await jobs.transition( + workspace_id, + job.id, + JobStatus.FAILED.value, + error_code=code, + error_message=safe_message, + ) + if job.social_post_target_id: + await self.social.publishing.posts.set_target_status( + workspace_id, + job.social_post_target_id, + PostStatus.FAILED.value, + error_code=code, + error_message=safe_message, + ) + await self.social.audit.record( + workspace_id=workspace_id, + event_type="SOCIAL_POST_FAILED", + provider=job.provider, + social_post_id=job.social_post_id, + social_job_id=job.id, + metadata={"error_code": code}, + ) + logger.warning( + "social publish failed", + extra={ + "workspace_id": workspace_id, + "provider": job.provider, + "social_post_id": job.social_post_id, + "job_id": job.id, + "retry_count": job.attempt_count, + "error_code": code, + }, + ) diff --git a/app/social/workers/scheduler.py b/app/social/workers/scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..5e89e4f90505eab3ee6b96629bab502ca9a28059 --- /dev/null +++ b/app/social/workers/scheduler.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio + +from app.core.logger import get_logger +from app.social.domain.enums import PostStatus +from app.social.services.social_service import SocialService +from app.social.workers.publisher import SocialPublisher + +logger = get_logger(__name__) + + +class SocialSchedulerWorker: + def __init__(self, social: SocialService, interval_seconds: int) -> None: + self.social = social + self.interval_seconds = interval_seconds + self.publisher = SocialPublisher(social) + self._task: asyncio.Task[None] | None = None + self._stop = asyncio.Event() + + async def start(self) -> None: + if self._task is not None or not self.social.ready: + return + self._stop.clear() + self._task = asyncio.create_task(self._run(), name="social-scheduler") + + async def stop(self) -> None: + self._stop.set() + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def tick(self) -> None: + if not self.social.ready: + return + due = await self.social.publishing.posts.claim_due_schedules() + for schedule in due: + post, _ = await self.social.publishing.posts.get_by_post_id_unscoped( + schedule.social_post_id + ) + try: + await self.social.publishing.queue( + post.workspace_id, + post.id, + idempotency_key=f"schedule:{schedule.id}", + ) + except Exception as exc: + # A scheduled output can expire after it was selected. The + # durable schedule was already claimed, so materialize a safe + # terminal post/target error instead of silently losing it. + latest, targets = await self.social.publishing.posts.get( + post.workspace_id, post.id + ) + code = getattr(exc, "code", "SOCIAL_SCHEDULE_PUBLISH_FAILED") + message = str(exc) if getattr(exc, "code", None) else "Scheduled publishing could not start." + for target in targets: + await self.social.publishing.posts.set_target_status( + post.workspace_id, + target.id, + PostStatus.FAILED.value, + error_code=code, + error_message=message, + ) + await self.social.publishing.posts.set_status( + post.workspace_id, latest.id, PostStatus.FAILED.value + ) + await self.social.audit.record( + workspace_id=post.workspace_id, + event_type="SOCIAL_POST_FAILED", + social_post_id=post.id, + metadata={"error_code": code}, + ) + logger.warning( + "scheduled social publish failed before queuing", + extra={"workspace_id": post.workspace_id, "social_post_id": post.id, "error_code": code}, + ) + await self.publisher.tick() + + async def _run(self) -> None: + while not self._stop.is_set(): + try: + await self.tick() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("social scheduler tick failed") + try: + await asyncio.wait_for(self._stop.wait(), timeout=self.interval_seconds) + except TimeoutError: + pass diff --git a/docs/social-automation.md b/docs/social-automation.md new file mode 100644 index 0000000000000000000000000000000000000000..c9e3725c1bef2b87673c140a2f8d112382795579 --- /dev/null +++ b/docs/social-automation.md @@ -0,0 +1,138 @@ +# MediaRouter Social Automation + +## Scope and architecture + +Phase 1 added a bounded `app/social/` domain without changing the existing media pipeline. Phase 2 implements YouTube through that domain: + +```text +Upload / URL → validation → FFmpeg / yt-dlp / Whisper → template → asset + │ + ▼ +media variant → social post → independent targets → durable jobs → provider adapter +``` + +REST, MCP, workers, the TypeScript/Python SDKs, n8n, and the Next.js application are thin transports over the same `SocialService` domain facade. Provider-specific calls belong only in `app/social/providers/`. + +All eight requested providers are registered. YouTube reports `implementation_status: implemented` and is `available` only when its backend Google client configuration is present. The remaining providers report `implementation_status: registered`, no publishing capabilities, and remain unimplemented. `available: false` distinguishes unavailable deployment configuration from implemented source code; no provider returns fake success. + +## Provider model + +| Provider | Connection | Account identity | Platform capabilities represented | Phase 1 adapter | +|---|---|---|---|---| +| YouTube | OAuth + PKCE | Channel ID | video upload/publish/status/delete and video statistics | Implemented | +| Facebook | Meta OAuth | Facebook Page | none exposed until implementation | Registered | +| Instagram | Meta OAuth | Professional Account | none exposed until implementation | Registered | +| TikTok | OAuth | Creator | none exposed until implementation | Registered | +| X | OAuth 2 | User | none exposed until implementation | Registered | +| LinkedIn | OAuth | Member or organization | none exposed until implementation | Registered | +| Telegram | Bot token | Bot/channel | none exposed until implementation | Registered | +| WhatsApp | Business API | Business account/phone number | none exposed until implementation | Registered | + +Capabilities describe what a platform/account type can support. `available` describes whether MediaRouter has a working adapter. Consumers must evaluate both. + +## Authentication, tenancy, and scopes + +All `/v1/social` routes except the provider callback use the existing API-key middleware. Required scopes are: + +- `social:accounts:read`, `social:accounts:write` +- `social:posts:read`, `social:posts:write`, `social:posts:publish` +- `social:schedules:read`, `social:schedules:write` +- `social:analytics:read` + +The callback cannot send an API key. It is authorized by a cryptographically random, expiring, atomic single-use OAuth state record. Workspace/user values are loaded from that server-side record and are never trusted from callback query parameters. + +The current backend has no durable human-user, profile, or workspace-membership database. It uses the authenticated API-key ID as its tenant/workspace boundary. Phase 2 adds a durable, workspace-scoped reference to existing MediaRouter output files for social publishing; it does not replace a future authoritative general asset service. Every account, post, job, output reference, campaign, and analytics query applies this boundary. + +## OAuth and token security + +OAuth state stores provider, tenant, initiating principal, exact callback URI, encrypted PKCE verifier, expiry, creation, and use time. Consumption is a conditional database update, so replay fails even under concurrent callbacks. When `SOCIAL_OAUTH_REDIRECT_BASE_URL` is configured, caller-supplied redirect URIs must match the server callback exactly. + +Only `TokenService` may access provider credentials. In Supabase Vault mode, `social_account_tokens` stores Vault UUID references, expiry, scopes, type, and refresh/revocation timestamps. Access and refresh tokens stay in Vault. Local development can use a Fernet-encrypted payload protected by `SOCIAL_OAUTH_ENCRYPTION_KEY`; plaintext storage is never supported. Token values are absent from schemas, REST/MCP/SDK responses, audit metadata, and logs. + +Apply migrations with a Supabase service-role database connection. RLS policies read the transaction-local `app.workspace_id`, which is set for every tenant-scoped account, token, post, job, and analytics operation. OAuth state is intentionally not a user-readable RLS resource: a provider callback has no user session, so its cryptographically random, expiring, atomic single-use state is its isolation boundary. Scheduler workers still require a trusted service role for cross-tenant claims; never expose that credential to a browser. + +## Database migrations + +Apply in order: + +1. `app/social/migrations/0001_social_foundation_postgres.sql` +2. `app/social/migrations/0002_social_rls.sql` +3. `app/social/migrations/0003_social_integrity_postgres.sql` +4. `app/social/migrations/0004_youtube_media_assets.sql` + +The first migration is additive and creates accounts/token references/capabilities, media variants, posts/targets/media, schedules, campaigns, jobs/attempts, OAuth state, webhook events, metrics, and social audit events with deliberate indexes and uniqueness constraints. The second enables tenant policies. The third adds database-owned `updated_at` maintenance and rejects cross-workspace relationship links. None drops customer data. At startup the service verifies that the complete Phase 1 table set exists but never applies SQL migrations itself. `SOCIAL_AUTO_MIGRATE=true` is for tests/local SQLite only and must remain false in production. + +The Phase 2 migration is additive. It adds `social_media_assets` to bind a workspace to an already-published MediaRouter output and encrypted `provider_state_encrypted` on jobs for resumable upload session state. It does not create duplicate users, profiles, workspaces, or memberships. + +## Job lifecycle, retries, and idempotency + +Validated job transitions are: + +```text +DRAFT → SCHEDULED → QUEUED → PREPARING → PROCESSING → UPLOADING → PUBLISHING → PUBLISHED + ↘ RETRYING ↗ ↘ FAILED + any active state → CANCELLED +``` + +Workers claim rows with `FOR UPDATE SKIP LOCKED` and change state in the claim transaction. Attempts are unique per job. Retryable failures are network errors and 429/500/502/503/504; 400, 403, media validation, capability, policy, and account-disconnect failures are terminal. A 401 attempts one refresh before requiring reauthorization. Backoff is immediate, 10 seconds, 30 seconds, 2 minutes, then 10 minutes, with jitter. + +Post creation and publication accept `Idempotency-Key`. Keys are unique per tenant; a replay with the same payload returns the original object/jobs, while reuse with a different payload returns `SOCIAL_IDEMPOTENCY_CONFLICT`. Each target has its own job and state, so mixed outcomes become `partial_success` rather than failing successful targets. An active job abandoned by a process restart is reclaimed after `SOCIAL_JOB_STALE_AFTER_SECONDS`; the same provider idempotency key is retained for the retry. + +Schedules require offset-aware timestamps plus a valid IANA timezone. The canonical instant is stored in UTC and the original timezone is retained. + +## REST API + +```text +GET /v1/social/providers +GET /v1/social/providers/{provider}/capabilities +GET /v1/social/assets +POST /v1/social/assets +GET /v1/social/accounts +GET /v1/social/accounts/{account_id} +POST /v1/social/accounts/{provider}/connect +GET /v1/social/accounts/{provider}/callback +POST /v1/social/accounts/{account_id}/refresh +DELETE /v1/social/accounts/{account_id} +POST /v1/social/posts +GET /v1/social/posts +GET /v1/social/posts/{post_id} +DELETE /v1/social/posts/{post_id} +POST /v1/social/posts/{post_id}/publish +POST /v1/social/posts/{post_id}/schedule +POST /v1/social/posts/{post_id}/cancel +GET /v1/social/jobs +GET /v1/social/jobs/{job_id} +GET /v1/social/accounts/{account_id}/analytics +GET /v1/social/posts/{post_id}/analytics +``` + +OpenAPI contains typed Pydantic request/response schemas. Social errors use the standard nested error object and request ID. + +## MCP, SDK, n8n, and frontend + +MCP registers `social.list_providers`, `social.get_capabilities`, `social.list_media_assets`, `social.register_media_asset`, `social.list_accounts`, `social.get_account`, `social.create_post`, `social.publish_post`, `social.schedule_post`, `social.cancel_post`, `social.get_post`, `social.get_job`, and `social.get_analytics`. Each calls `SocialService` and requires both MCP transport access and the appropriate social scope. + +The official SDKs expose `client.social` with provider/account/post/schedule/job/analytics operations. The n8n `MediaRouter Social` node consumes this SDK only; it contains no provider HTTP client. Dynamic provider/account options, batch items, idempotency keys, and Continue On Fail follow existing node conventions. + +The authenticated Next.js routes are `/social`, `/social/accounts`, `/social/posts`, `/social/calendar`, `/social/campaigns`, and `/social/analytics`. Provider/account/post state comes through the session-aware BFF. Provider credentials never use `NEXT_PUBLIC_*`. + +## Environment + +Required for production social persistence: + +```env +SOCIAL_DATABASE_URL=postgresql+asyncpg://... +SOCIAL_AUTO_MIGRATE=false +SOCIAL_JOB_STALE_AFTER_SECONDS=900 +SOCIAL_OAUTH_ENCRYPTION_KEY=<32+ random bytes> +SOCIAL_OAUTH_REDIRECT_BASE_URL=https://api.example.com +SUPABASE_VAULT_ENABLED=true +``` + +Provider credentials are backend-only: `GOOGLE_CLIENT_ID/SECRET`, `META_CLIENT_ID/SECRET`, `TIKTOK_CLIENT_KEY/SECRET`, `LINKEDIN_CLIENT_ID/SECRET`, `X_CLIENT_ID/SECRET`, `TELEGRAM_BOT_TOKEN`, and `WHATSAPP_CLIENT_ID/SECRET`. Add only the providers being configured. Never place these values in frontend environment variables. + +## Deployment and Phase 2 + +Hugging Face continues to bind `PORT=7860`; the social worker is CPU-light and introduces no GPU requirement. Missing social schema/provider configuration logs a clear warning and leaves existing media endpoints operational. Basic startup does not require Supabase. + +YouTube Phase 2 implements token exchange, channel discovery, upload, publish/status reconciliation, deletion, and video statistics. See [social-youtube.md](social-youtube.md) for Google setup, media requirements, metadata, retries, scheduling, quotas, troubleshooting, and live-test status. AI caption/hashtag generation, campaign automation, advanced analytics, content intelligence, reposting, and AI video generation remain out of scope. diff --git a/docs/social-meta-production-readiness.md b/docs/social-meta-production-readiness.md new file mode 100644 index 0000000000000000000000000000000000000000..7c1546aaad851c591695b243aecf0e8ca9272c7b --- /dev/null +++ b/docs/social-meta-production-readiness.md @@ -0,0 +1,183 @@ +# Meta social production-readiness audit + +Status date: 2026-07-31. This is a Phase 3C hardening and analytics audit of +the code currently checked out. It is deliberately evidence-based: it does +not certify functionality that is absent from this checkout or has not been +run against a dedicated Meta test app. + +## Architecture + +All social transports use `SocialService`. REST, MCP, SDKs, n8n, the frontend, +scheduler, retry worker, audit service, account repository, and token service +remain shared. Meta analytics calls use official Graph API requests only: + +```text +REST / MCP / SDK / n8n / frontend + -> SocialService -> AnalyticsService + -> TokenService -> Meta Graph API v25.0 + -> social_post_metrics + safe raw_metrics +``` + +`META_GRAPH_API_VERSION` defaults to `v25.0`, which was the latest version +shown by the official Meta documentation reviewed for this work. The URL is +constructed from that configuration; no version is hard-coded in the adapter. + +## Analytics implemented in this phase + +Facebook Page post analytics use the official Page-post object fields and +insights request. Only returned values are normalized: impressions, +video-views, reactions/likes, comments, shares, engagement and click fields +when present. Empty fields are not converted to zero. + +Instagram Professional post analytics first reads the media's +`media_product_type`, then queries only its documented metrics: + +- Reels: views, reach, likes, comments, shares, saves. +- Feed video: views plus reach, likes, comments, shares, saves. +- Feed image: reach, likes, comments, shares, saves. +- Album/per-item insight data: unavailable. The implementation does not invent + a carousel aggregate. + +Meta can return an empty data set when insights do not exist or are delayed; +this is recorded as unavailable rather than zero. `raw_metrics` retains the +safe provider response used for normalization; it is not a fabricated schema. + +Account-level endpoints report authorization readiness only. The existing +`social_post_metrics` model is post-target scoped, so Page/profile aggregates +are not squeezed into an unrelated row. + +## Permissions and OAuth + +Normal account connection does not request analytics permissions. A client +must explicitly send `authorization_purpose: "analytics"` while reconnecting. +The explicit scope sets are: + +- Facebook Page: `pages_read_engagement`, `read_insights` +- Instagram Professional: `instagram_basic`, `instagram_manage_insights` + +Before a Graph analytics request, `AnalyticsService` checks the scopes stored +by `TokenService`. Missing scopes return +`META_ANALYTICS_ADDITIONAL_AUTHORIZATION_REQUIRED`, with the missing scope +names but never a credential. Existing OAuth state handling remains random, +short-lived, single-use, provider-bound, workspace-bound, redirect-bound, and +PKCE-backed where the provider supports it. + +Production Meta application setup still needs a developer app, Facebook Login, +the app's exact server-side callback URI, HTTPS App Domains, valid test users, +and appropriate App Review approval. Meta's permissions and allowed metrics +can vary by login product, account type, region, and app review status. Use a +dedicated test Page and Professional account before requesting production +access. + +## Token and tenant security + +`TokenService` is the only supported credential store/reader. The Meta Graph +client sends `Authorization: Bearer` rather than putting access tokens in query +strings. Provider errors are mapped to fixed safe messages: + +- authentication -> `SOCIAL_REAUTH_REQUIRED` +- permission -> `SOCIAL_PERMISSION_DENIED` +- rate limit -> `SOCIAL_RATE_LIMITED` +- 5xx / transient Graph failure -> retryable provider-unavailable error + +Public account, job, media, post-target, analytics, and audit serialization +recursively strips token-, secret-, authorization-, cookie-, password-, +credential-, and API-key-like fields. This is defense in depth; it does not +relax the rule that access tokens must live only in encrypted TokenService +storage (or the configured vault). + +Tenant lookups for accounts, posts, jobs, assets, schedules, tokens, and +analytics are workspace-scoped. Postgres RLS migrations use the transaction +local `app.workspace_id`; service-role access is backend-only and must never +be exposed to the browser, SDK, MCP, or n8n. + +## Publishing, scheduling, status, and deletion + +This checkout does not contain the claimed Phase 3A Meta OAuth/discovery or +Phase 3B Meta publishing code. Its Facebook and Instagram adapters are still +registered-only and explicitly have no publish, container/reconciliation, +delete, native-schedule, Reel, or carousel implementation. Therefore this +phase does not advertise or certify those capabilities. The existing YouTube +worker remains unchanged. + +The shared job state machine, idempotency keys, retry classification, durable +provider state, UTC scheduling, and audit path exist. They have not been +validated for Meta because there is no Meta publishing implementation in this +checkout. In particular, Meta provider-success/database-failure reconciliation +cannot be certified until a durable Meta external-ID/container state is added. + +## Transport clients + +The existing `/v1/social` analytics routes, `social.get_analytics` MCP tool, +Python `client.social`, TypeScript `client.social`, n8n Social node, and +frontend analytics page continue to use `SocialService`. Type definitions now +include analytics unavailable reasons, explicit required scopes, normalized +metric fields, and the opt-in analytics connection purpose. No Meta access +token is accepted or emitted by any of these clients. + +The frontend reads provider capabilities from the backend and renders the +normalized metric values and unavailable reasons. It makes no static claim +that Meta analytics is granted. + +## Optional live test + +Normal CI uses mocked Graph API tests only. To run the non-publishing Page +post-insights smoke test, set: + +```text +RUN_META_INTEGRATION_TESTS=true +META_TEST_PAGE_ACCESS_TOKEN= +META_TEST_PAGE_POST_ID= +``` + +The test is skipped, not passed, when that opt-in and the dedicated values are +absent. Browser OAuth, Page discovery, Professional-account discovery, +Facebook publishing, Instagram publishing, Reels, carousel, status +reconciliation, scheduling, and deletion require the missing Phase 3A/3B +implementation plus a dedicated test application; they are **NOT VERIFIED**. +Never use a personal production account or token in CI logs. + +## App Review and deployment checklist + +- Configure `META_APP_ID`, `META_APP_SECRET`, and `META_GRAPH_API_VERSION` on + the backend only. Legacy `META_CLIENT_ID`/`META_CLIENT_SECRET` remain + supported during migration. +- Keep `META_APP_SECRET`, page tokens, user tokens, vault credentials, and + service-role credentials out of browser variables and build-time frontend + configuration. +- Register the exact `https:///v1/social/accounts//callback` + URI and App Domain before testing OAuth. +- Add only reviewed Meta products and permissions. Use the explicit analytics + reconnect for analytics scopes; do not broaden normal publishing consent. +- Document tester roles, data-deletion instructions, privacy policy, screencast + evidence, and least-privilege rationale for App Review. +- Apply all social migrations, enable RLS on Postgres, and limit migration, + vault, and service-role credentials to backend workers. +- Monitor rate-limit/re-authentication events without logging provider + responses, authorization headers, callback codes, or tokens. + +## Final Phase 3 audit + +| Area | Status | Evidence / limitation | +| --- | --- | --- | +| Graph API analytics transport | NOT VERIFIED | Mock coverage was added for v25.0 URL, bearer header, safe error mapping, Facebook and Reel normalization, but the host cannot run the Python suite. | +| Analytics permission gating | NOT VERIFIED | Explicit reconnect and stored-scope checks were implemented; backend tests could not run on this host. | +| Token output/audit redaction | NOT VERIFIED | Public account/job/analytics/audit serializers were hardened, but their new backend test is unrun. | +| Workspace isolation | NOT VERIFIED | Tenant-scoped repositories and existing coverage are present; backend and Meta-specific RLS tests were not run. | +| OAuth state and redirect controls | NOT VERIFIED | State replay/expiry/provider mismatch and backend-owned redirect tests exist but were not run. | +| Retry classification | NOT VERIFIED | Generic 429/5xx/network and bounded-401 coverage exists but was not run. | +| Facebook OAuth and Page discovery | BLOCKER | Not implemented in this checkout. | +| Instagram Professional OAuth/discovery | BLOCKER | Not implemented in this checkout. | +| Facebook image/video publishing | BLOCKER | Not implemented in this checkout. | +| Instagram image/video/Reels/carousel publishing | BLOCKER | Not implemented in this checkout. | +| Meta container status reconciliation | BLOCKER | Not implemented in this checkout. | +| Meta schedule/delete/idempotent recovery | BLOCKER | Cannot be tested without Meta publishing implementation. | +| MCP/n8n/SDK Meta end-to-end | NOT VERIFIED | Shared APIs are typed, but Meta accounts cannot be connected/published here. | +| Frontend Meta end-to-end | NOT VERIFIED | It is backend-driven, but missing connection/publishing phases prevent validation. | +| Live Meta integration | NOT VERIFIED | Opt-in test has no dedicated credentials in this environment. | +| YouTube regression | WARNING | Source paths were preserved; full suite must be run in a dependency-complete CI environment. | +| Docker and package production builds | NOT VERIFIED | Must be run in dependency-complete CI; not a certification. | + +Phase 3 is **not production-ready** and cannot be marked complete from this +checkout. The Meta foundation and publishing blockers must be supplied and +verified before Phase 4 may begin. diff --git a/docs/social-production-readiness-audit.md b/docs/social-production-readiness-audit.md new file mode 100644 index 0000000000000000000000000000000000000000..a79e2890662995657f21b86fdf5a099792e59140 --- /dev/null +++ b/docs/social-production-readiness-audit.md @@ -0,0 +1,193 @@ +# Social Automation Phase 1 production-readiness audit + +Audit date: 2026-07-30. This is an evidence-based audit of the Phase 1 +foundation; it does not certify any real social-provider publishing adapter. + +## Overall result: NOT PRODUCTION-READY FOR MULTI-USER SOCIAL PUBLISHING + +The bounded-domain design, application-level tenant predicates, OAuth state +handling, rate-limit scopes, retry policy, and token response boundaries are in +place. The deployment cannot be certified because PostgreSQL/RLS and Docker +could not be run in this environment, and because the pre-existing platform +does not yet have authoritative workspace membership or media-asset ownership +tables. + +## Evidence and results + +| Area | Result | Evidence / limitation | +|---|---|---| +| Python source syntax | PASS | `python -m compileall -q app tests sdk/python/media_platform sdk/python/tests main.py` | +| Python static checks | PASS | `pyflakes`, `isort --check-only`, and `pycodestyle --ignore=E501,W503` on social code and tests | +| OAuth callback contract | PASS | callback URI is backend-owned, HTTPS-only outside localhost, state is random/expiring/single-use, and OpenAPI now correctly marks callback auth as state-based | +| OAuth adversarial cases | WARNING | tests cover replay, expiry, wrong provider, malformed state, and PKCE storage; cannot run without backend test dependencies or provider adapter | +| Token response/logging boundary | PASS (static) | token fields are absent from REST/MCP/SDK/frontend/n8n social output models and structured social logs do not receive token values | +| Token isolation | WARNING | repository now joins the owning account and filters workspace; PostgreSQL RLS execution remains unverified | +| API key/scopes | PASS (static) | central `ScopePolicy` assigns social scopes and MCP tools independently require the same scopes | +| Workspace A/B service isolation | WARNING | service tests cover account/post/job/token/analytics read and mutation denial; cannot run here and does not replace PostgreSQL RLS testing | +| PostgreSQL migrations 0001–0003 | BLOCKER | no PostgreSQL-compatible server, client, or disposable `SOCIAL_TEST_DATABASE_URL` was available | +| Foreign keys, constraints, indexes, JSONB, timestamps | WARNING | present in migration source; runtime PostgreSQL execution is required to validate SQL and existing-schema compatibility | +| RLS policies | BLOCKER | source inspection shows tenant policies and `WITH CHECK` clauses, but isolation was not verified using a non-owner role | +| Idempotent post/job creation | WARNING | unique tenant keys, request fingerprints, and race recovery exist; no live database/process-restart test was possible | +| External exactly-once publishing | BLOCKER | a retained idempotency key can only provide exactly-once effects once each real provider adapter forwards/enforces its platform-specific idempotency semantics | +| Job transitions/retry classification | PASS (static) | transition validation and explicit 429/5xx/network retry matrix exist; 401 refresh is limited to attempt one | +| Worker crash recovery | WARNING | stale active jobs are reclaimed after `SOCIAL_JOB_STALE_AFTER_SECONDS`; requires a live worker/database crash-recovery test | +| Scheduling | PASS (static) | aware future timestamps, IANA zones, UTC normalization, cancellation, and concurrent unique-schedule handling are implemented | +| REST endpoint authorization matrix | WARNING | central middleware/static policy checked; full API matrix requires executable backend dependencies | +| MCP boundary | PASS (static) | social tools call `SocialService`, use `auth_context`, and expose response schemas only | +| SDK/n8n boundary | PASS (static) | SDKs use REST and n8n uses the TypeScript SDK; neither contains provider transport logic | +| Frontend secret boundary/CORS | PASS (static) | browser uses same-origin `/api/backend` BFF, `connect-src 'self'`, and server-only `MEDIAROUTER_*_API_TOKEN`; no frontend social credential is public | +| Frontend tests/build | PASS | Typecheck, lint, tests (68/68), and Next production build completed before this backend-only audit amendment | +| TypeScript SDK | PASS | lint, tests (3/3), and build completed | +| Python SDK | PASS | `unittest discover` passed (3/3) | +| n8n community node | PASS | lint, tests (3/3), and build completed; upstream `n8n-workflow` source-map warnings only | +| Backend pytest suite | BLOCKER | current host has no `pytest`, SQLAlchemy, asyncpg, or Pydantic installed | +| Docker build/startup | BLOCKER | current host has neither Docker nor Podman | + +## Audit remediation performed + +- OAuth initiation now requires `SOCIAL_OAUTH_REDIRECT_BASE_URL`; caller input + must match `https:///v1/social/accounts//callback` exactly. +- OAuth callback query fields are bounded and format-validated. The public-path + exemption now only matches the exact callback route, and OpenAPI declares + that callback as state-authenticated rather than bearer-authenticated. +- Schedules reject past and naive timestamps, validate IANA zones, normalize to + UTC, and recover from a concurrent unique-schedule insert race. +- Token repository get/save/revoke operations additionally constrain through + `social_accounts.workspace_id`, protecting SQLite and privileged database + roles in addition to RLS. +- Stale worker claims are retried or failed after a configurable lease timeout + while retaining the same provider idempotency key. +- Social readiness now requires the complete Phase 1 table set rather than only + `social_accounts`; it reports missing tables without attempting migration. +- `0002_social_rls.sql` has explicit child-table `WITH CHECK` policies. + `0003_social_integrity_postgres.sql` adds `updated_at` triggers, state checks, + and triggers that reject cross-workspace relationship links. + +## Migration review + +Apply the files in numeric order in a controlled deployment transaction: + +1. `app/social/migrations/0001_social_foundation_postgres.sql` +2. `app/social/migrations/0002_social_rls.sql` +3. `app/social/migrations/0003_social_integrity_postgres.sql` + +They are additive and transactional. They create JSONB columns, intentional +indexes, foreign keys, uniqueness constraints, RLS policies, and integrity +triggers. Do not set `SOCIAL_AUTO_MIGRATE=true` in production; the application +uses it only for local SQLite/test metadata creation and does not execute SQL +migrations at normal startup. + +The audit could not execute the SQL against PostgreSQL. In particular, do not +assume a table owner proves RLS: use a non-owner application role with table +grants, set `app.workspace_id` transaction-locally, and prove Workspace A cannot +read or write Workspace B rows and child rows. + +## Required database validation script + +Use a disposable PostgreSQL/Supabase project only, never production data. + +1. Apply 0001, 0002, then 0003 and inspect `pg_constraint`, `pg_indexes`, and + `pg_trigger` for each social table. +2. Create two workspaces and a non-owner application role. Within separate + transactions call `set_config('app.workspace_id', 'workspace-a', true)` and + `set_config('app.workspace_id', 'workspace-b', true)`. +3. Create account/post/target/job/token-reference/metric data in both tenants. + Attempt select, update, delete, and insert cross-links from each tenant. +4. Verify policy `USING` and `WITH CHECK` reject all cross-tenant actions, + including tokens, capabilities, post media, schedules, metrics, and attempts. +5. Exercise duplicate post/job idempotency keys with equal and unequal request + fingerprints; run concurrent schedule creates; simulate a stale active job. +6. Run the PostgreSQL-gated integration test suite using + `SOCIAL_TEST_DATABASE_URL` and a database role that cannot bypass RLS. + +## Security findings + +### BLOCKER — tenant identity is an API-key identifier + +Phase 1 uses `AuthContext.api_key_id` for both `workspace_id` and `user_id`. +That protects different API keys from one another, but a shared role key used +by several frontend users is a shared social tenant. Before enabling +multi-user/social production publishing, introduce authoritative `users`, +`workspaces`, `workspace_members`, and asset ownership; resolve the workspace +server-side from the authenticated human principal/API key association. + +### BLOCKER — media asset ownership cannot be enforced yet + +The existing media engine has no durable workspace-aware asset repository. +`social_posts.media_asset_id` is therefore an opaque reference. Before real +publishing, create/integrate the authoritative media asset table and enforce: + +- the creator/user owns or can access the asset; +- the asset belongs to the resolved workspace; +- a variant belongs to the source asset and the same workspace; +- post media references inherit the post tenant; +- provider worker fetches use an authorized, durable asset locator. + +### WARNING — callback completion UX + +OAuth provider callbacks terminate on the Hugging Face backend and return the +safe account representation. This keeps client secrets server-only, but a +production UI should issue a short-lived server-side completion ticket and +redirect to an allow-listed Vercel frontend completion page. Do not accept +arbitrary frontend redirect URLs. + +### WARNING — trusted worker database role + +Schedulers claim jobs without a tenant setting by design. The worker database +credential must be a trusted role that can process cross-tenant due work; a +browser-facing or RLS-restricted role will not work for this path. Document and +test the exact Supabase/Postgres role before deployment. + +## Environment review + +Backend secrets are not declared with public names. Provider credentials, +Supabase service-role key, `SOCIAL_OAUTH_ENCRYPTION_KEY`, and server API tokens +remain backend-only. The frontend example exposes URLs and feature flags only; +it uses `MEDIAROUTER_*` (without `NEXT_PUBLIC_`) for server-side backend keys. + +For social production, set: + +```env +SOCIAL_DATABASE_URL=postgresql+asyncpg://... +SOCIAL_AUTO_MIGRATE=false +SOCIAL_OAUTH_ENCRYPTION_KEY=<32-or-more-random-bytes> +SOCIAL_OAUTH_REDIRECT_BASE_URL=https:// +SUPABASE_VAULT_ENABLED=true +``` + +Do not configure any optional provider credential until its adapter and provider +review are complete. Application startup must still work without them; social +operations will truthfully report unavailable/foundation capabilities. + +## Docker and deployment + +The Dockerfile targets Python 3.10, port 7860, and copies `app/`, so all social +migrations ship in the image. Docker was unavailable on this Android/Termux +host, so the following remains mandatory before release: + +```sh +docker build -t mediarouter-social-audit:local . +docker run --rm -p 7860:7860 \ + -e AUTH_ENABLED=false \ + -e SOCIAL_AUTO_MIGRATE=false \ + -e SOCIAL_ENABLED=true \ + mediarouter-social-audit:local +curl -fsS http://127.0.0.1:7860/health +``` + +Confirm startup succeeds without optional social provider credentials, no +database schema is created/mutated by the social service, and existing media +routes retain their previous behavior. + +## Phase 2 prerequisites + +1. A disposable PostgreSQL/Supabase CI database and non-owner RLS test role. +2. Successful execution of every migration and Workspace A/B isolation test. +3. Native workspace, membership, and asset/variant ownership integration. +4. Container build/startup verification on Python 3.10. +5. Provider-specific security review before each adapter: OAuth scopes, + redirect registration, PKCE, token exchange, refresh/revocation, upload, + provider idempotency, webhook signature validation, rate limits, and error + redaction. +6. A controlled OAuth callback completion redirect to the Vercel application. +7. Live API/MCP/n8n/SDK contract tests against the deployed OpenAPI schema. diff --git a/docs/social-tiktok-foundation.md b/docs/social-tiktok-foundation.md new file mode 100644 index 0000000000000000000000000000000000000000..8dbbc4b9f7223c175e7448abe88c70888177e896 --- /dev/null +++ b/docs/social-tiktok-foundation.md @@ -0,0 +1,182 @@ +# TikTok foundation and OAuth + +Phase 4A adds TikTok Login Kit account connection to the existing MediaRouter +Social Automation architecture. It uses TikTok's official v2 web OAuth and +Display API endpoints. It does not add Content Posting, upload, scheduling, +post status, deletion, or TikTok publishing capabilities. + +Phase 4B now adds approval-gated Direct Post video publishing without changing +this foundation. See [TikTok publishing](social-tiktok-publishing.md). The +statements below describe the intentionally narrower Phase 4A capability set. + +The implementation was checked on 2026-07-31 against TikTok's current official +documentation: + +- [Login Kit for Web](https://developers.tiktok.com/doc/login-kit-web) +- [OAuth user access token management](https://developers.tiktok.com/doc/oauth-user-access-token-management) +- [Get User Info](https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info) +- [Scopes overview](https://developers.tiktok.com/doc/scopes-overview) +- [Content Posting API overview](https://developers.tiktok.com/doc/content-posting-api-get-started) + +## Architecture + +```text +frontend / REST / MCP / TypeScript SDK / Python SDK / n8n + | + SocialService + | + OAuthService + / state \ account + OAuthStateService AccountService + | | + encrypted verifier TokenService + \ / + TikTokProvider + | + Official TikTok APIs +``` + +TikTok remains a normal provider in `/v1/social`. There is no TikTok-specific +REST, MCP, SDK, or n8n business layer. + +## Backend configuration + +Configure these values only in the backend environment: + +```dotenv +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +TIKTOK_REDIRECT_URI=https://api.example.com/v1/social/accounts/tiktok/callback +SOCIAL_OAUTH_ENCRYPTION_KEY= +``` + +`TIKTOK_CLIENT_SECRET` and every user token are server credentials. Do not use +`NEXT_PUBLIC_` variables for them and do not copy them into browser, MCP, SDK, +or n8n configuration. `SOCIAL_OAUTH_ENCRYPTION_KEY` must contain at least 32 +bytes of unpredictable material for encrypted OAuth state and the local +development token store. Production Supabase deployments may use the existing +Vault-backed TokenService configuration. + +`TIKTOK_REDIRECT_URI` must be the exact HTTPS callback registered with TikTok. +MediaRouter accepts only its existing callback path: + +```text +/v1/social/accounts/tiktok/callback +``` + +TikTok requires the same redirect URI during authorization and token exchange. +MediaRouter validates it as backend-owned and does not let a frontend request +replace it. HTTP is accepted only for localhost development by the shared OAuth +validation policy. + +## TikTok developer application setup + +1. Create or select an application in the TikTok for Developers portal. +2. Add the **Login Kit** product and add a **Web** platform. +3. Register the exact `TIKTOK_REDIRECT_URI` shown above. Configure the public + website/domain, terms of service URL, privacy policy URL, and application + branding required by TikTok. +4. Request and obtain access to `user.info.basic`. A normal Phase 4A connection + requests only this scope. +5. Add the dedicated development/test TikTok accounts allowed by the portal and + complete a consent flow before testing account discovery. +6. Submit the application, product, scope usage, redirect/domain configuration, + and requested evidence for TikTok review before allowing production users. + +TikTok development applications and unreviewed products can be limited to +approved test users. Availability in the developer portal does not imply that +an app has production approval. Content Posting API access and the +`video.publish`/`video.upload` scopes are separate products and are not requested +or implemented in Phase 4A. + +## OAuth flow + +1. An authenticated workspace calls + `POST /v1/social/accounts/tiktok/connect`. +2. OAuthService creates a cryptographically random, expiring state record bound + to the provider, workspace, initiating user, and exact redirect URI. +3. The frontend navigates to TikTok's official + `https://www.tiktok.com/v2/auth/authorize/` URL. +4. TikTok returns an authorization code to the common MediaRouter callback. +5. OAuthStateService atomically consumes the state. Invalid, expired, reused, + or wrong-provider state is rejected before any token exchange. +6. The backend exchanges the code at + `https://open.tiktokapis.com/v2/oauth/token/` using its client secret. +7. The backend discovers the authenticated user at + `https://open.tiktokapis.com/v2/user/info/`. +8. AccountService upserts the workspace-owned SocialAccount and TokenService + stores the returned access/refresh credentials. + +TikTok's current web Login Kit contract does not define PKCE parameters for a +confidential web client. Its token documentation requires `code_verifier` only +for mobile and desktop applications. MediaRouter therefore uses its existing +server-side state protections for the web flow and does not send undocumented +PKCE fields to TikTok. Other providers continue using PKCE through the same +OAuth architecture where their official web flow supports it. + +## Account identity and connection lifecycle + +MediaRouter uses TikTok `open_id` as the stable provider identity because it is +the user identifier scoped to the TikTok application/client. A connected +SocialAccount stores: + +- provider `tiktok`; +- account type `creator`; +- external account ID (`open_id`); +- display name and avatar when TikTok returns them; +- safe provider metadata containing `tiktok_open_id` and, when returned, + `tiktok_union_id`; and +- the normal connected/reauthorization/disconnected status. + +The base `user.info.basic` scope does not provide `username`; MediaRouter leaves +that field absent instead of silently requesting `user.info.profile` or +fabricating a handle. Reconnecting the same `open_id` in one workspace updates +the existing account and credentials. The database uniqueness constraint +prevents duplicate workspace/provider/external-ID connections, including +concurrent callbacks. The same TikTok identity may be connected independently +to a different workspace without crossing tenant boundaries. + +Access tokens currently expire after the provider-reported `expires_in` period +(TikTok documents 24 hours) and are refreshed server-side with the returned +refresh token (documented as valid for up to 365 days). TikTok can rotate the +refresh token; TokenService persists the new response atomically. Provider +rejection, revocation, or an expired/missing refresh token becomes +`SOCIAL_REAUTH_REQUIRED`. Disconnect calls TikTok's official OAuth revoke +endpoint, then revokes local credentials and marks the account disconnected. + +## Capabilities and clients + +Provider and account data is returned dynamically by the backend. With valid +client configuration, TikTok is `implemented` and available for OAuth/account +discovery. The following publishing flags remain false: + +- video/image upload; +- direct or draft publishing; +- provider or MediaRouter scheduling; +- publish status reconciliation; and +- post deletion. + +Consequently `social.create_post`, `social.publish_post`, and +`social.schedule_post` cannot target TikTok in Phase 4A. MCP, both SDKs, n8n, +and the frontend can discover TikTok, inspect its capabilities, start the +common OAuth connection, list the connected identity, reconnect it, and +disconnect it without receiving provider credentials. + +## Security and operational limitations + +TokenService is the only credential persistence/retrieval layer. Provider +secrets and tokens are absent from public schemas, REST responses, frontend +state/storage, MCP results, n8n output, SDK models, audit metadata, and normal +logs. TikTok errors are normalized without returning provider descriptions or +request payloads that could contain credentials or authorization codes. + +Workspace-scoped repositories and existing Postgres RLS protect account and +token records. The public callback accepts no workspace/user input; it derives +both from the single-use state record. A callback for another provider cannot +consume TikTok state. + +Phase 4A is mock-testable without TikTok credentials. An actual browser consent +flow remains dependent on a configured, reviewed TikTok application and an +eligible development/test or production user. No live OAuth result should be +reported as verified merely because the three TikTok environment variables are +present. diff --git a/docs/social-tiktok-production-readiness.md b/docs/social-tiktok-production-readiness.md new file mode 100644 index 0000000000000000000000000000000000000000..24d100ccc1632cab5378f5a93909c32935b3b3c6 --- /dev/null +++ b/docs/social-tiktok-production-readiness.md @@ -0,0 +1,284 @@ +# TikTok production readiness (Phase 4C) + +This document is the Phase 4C implementation and verification record for +TikTok. It covers the Phase 4A Login Kit foundation, Phase 4B Direct Post video +publishing, and Phase 4C analytics and hardening. It does not cover X, +LinkedIn, Telegram, WhatsApp, or any Phase 5 work. + +The official TikTok documentation and endpoint contracts referenced here were +reviewed on 2026-07-31. Passing mocked tests proves MediaRouter behavior against +those contracts; it does not prove that a particular TikTok application has +been approved or that live provider behavior is available to that application. + +## Architecture + +```text +frontend / REST / MCP / TypeScript SDK / Python SDK / n8n + | + SocialService + / accounts / jobs / analytics / scheduler + | + OAuthService -- TokenService -- SocialProviderAdapter + | + TikTokProvider + | + Login Kit / Display API / official Content Posting API / upload host +``` + +TikTok remains inside `/v1/social` and the existing SocialService architecture. +Provider-specific logic is confined to the adapter and typed target metadata. +SocialPost, SocialTarget, SocialJob, MediaAsset, MediaVariant, scheduler, +RetryService, idempotency, AuditService, MCP, n8n, and both SDKs remain shared. + +## OAuth, scopes, and account connection + +The confidential web flow uses TikTok Login Kit v2. MediaRouter creates a +cryptographically random, expiring, single-use state bound to provider, +workspace, user, and exact backend redirect URI. The callback consumes state +atomically before code exchange. A wrong provider, workspace, redirect, +expired state, or replay cannot reach token exchange. TikTok's current web +Login Kit contract does not define PKCE for this confidential-client flow; +MediaRouter does not send undocumented mobile/desktop PKCE fields. + +OAuth authorization is deliberately separated by purpose: + +| Purpose | Scopes requested | +|---|---| +| connection | `user.info.basic` | +| publishing | `user.info.basic`, `video.publish` | +| analytics | `user.info.basic`, `video.list` | + +Normal connection and publishing never silently request `video.list`. +Analytics returns +`TIKTOK_ANALYTICS_ADDITIONAL_AUTHORIZATION_REQUIRED` with the required scope +when the connected credential lacks it. Publishing similarly requires an +explicit publishing authorization. + +The callback discovers the account with official `/v2/user/info/` and persists +TikTok `open_id` as the stable application-scoped identity. Duplicate +workspace/provider/external-ID connections are prevented by the shared unique +constraint. Reconnect updates that identity; disconnect uses the official +revoke endpoint and invalidates local credentials. + +## Token handling and redaction + +TokenService is the only credential persistence and retrieval layer. It stores +access/refresh tokens, expiry, and granted scopes; handles provider-reported +rotation; and maps an unusable token to `SOCIAL_REAUTH_REQUIRED`. + +Credentials are excluded from public Pydantic views. Recursive sanitization is +applied before provider metadata, raw analytics, and audit metadata are +persisted or returned. Structured log formatting redacts sensitive keys and +credential-shaped strings, including bearer and query/form token forms. n8n +also sanitizes provider/SDK errors before returning Continue On Fail output. +Tokens and client secrets must never be copied into frontend environment +variables, browser storage, MCP arguments/results, SDK objects, or n8n +credentials. + +## Publishing and media validation + +Direct Post is fail-closed behind `TIKTOK_DIRECT_POST_ENABLED`. Enabling the +flag does not grant TikTok product access or application approval. A complete +publish uses only the current official endpoints: + +1. `/v2/post/publish/creator_info/query/` for creator-specific options; +2. `/v2/post/publish/video/init/` with `FILE_UPLOAD`; +3. sequential ranged `PUT` requests to TikTok's validated upload host; and +4. `/v2/post/publish/status/fetch/` for reconciliation. + +The service verifies workspace ownership of account, post, target, job, asset, +and selected variant before touching the provider. Fresh FFprobe output is +validated for readable file, MIME/container, codec, duration, frame rate, +dimensions, finite aspect ratio, size, and optional audio codec. MediaRouter +does not silently alter media. Callers may create a compatible MediaVariant +through the existing FFmpeg/template pipeline and publish that owned variant. + +Large video is streamed in TikTok-compliant sequential chunks; it is never +loaded into one application-wide buffer. The durable encrypted provider state +tracks initialization, publish ID, upload session, byte plan, and committed +offset. + +## Scheduling and status reconciliation + +TikTok native scheduling is not advertised. The shared MediaRouter scheduler +stores canonical UTC and the supplied IANA timezone, then starts the same +Direct Post workflow when due. + +Official publishing statuses normalize as follows: + +| TikTok | MediaRouter | +|---|---| +| `PROCESSING_UPLOAD`, `PROCESSING_DOWNLOAD`, `SEND_TO_USER_INBOX` | `PROCESSING` | +| `PUBLISH_COMPLETE` | `PUBLISHED` | +| `FAILED` | `FAILED` | +| unknown/invalid/unqueryable publish ID | `UNAVAILABLE` | + +`publish_id` remains the private reconciliation identifier. When TikTok returns +public post IDs after completion, they are stored separately as safe provider +metadata. TikTok currently exposes no supported delete endpoint for this +workflow, so `delete_post=false` and MediaRouter never fabricates deletion. + +## Analytics + +With explicit `video.list` consent, the adapter calls the official Display API: + +```text +POST https://open.tiktokapis.com/v2/video/query/ +``` + +Queries are limited to public TikTok video IDs owned by the authorized user. +MediaRouter normalizes only provider-returned `view_count`, `like_count`, +`comment_count`, and `share_count` into `social_post_metrics`. Safe original +fields are retained in `raw_metrics` JSONB. Missing values remain missing; no +metric is inferred or fabricated. The analytics service resolves the public +video ID produced by status reconciliation and does not mistake the private +`publish_id` for a Display API video ID. + +Analytics capability is available only when the adapter implementation and +backend configuration exist. Per-account execution additionally checks the +stored `video.list` grant and returns a structured unavailable response when +additional authorization is required. + +## Retries and idempotency + +HTTP 429, 500, 502, 503, 504, and network timeouts are transient and use the +shared bounded backoff/attempt policy. Ordinary 400, invalid metadata/media, +unsupported operations, and permission failures fail immediately. A 401 gets +one TokenService refresh/retry path and then requires reauthorization; there is +no infinite retry loop. + +The request idempotency key and payload fingerprint identify one logical post +and job. The same key and same payload returns that operation. The same key and +a different payload returns `SOCIAL_IDEMPOTENCY_CONFLICT`. Worker retries and +restarts reuse the original job. + +Before TikTok initialization, the worker durably records an encrypted +`tiktok_init_started` marker. After initialization, it persists the publish ID +and upload recovery state before proceeding. On restart it reconciles/resumes +the existing operation. If TikTok may have accepted initialization but the ID +could not be persisted, MediaRouter fails closed instead of submitting again; +TikTok offers no client idempotency key or lookup-by-client-key endpoint that +could safely prove otherwise. + +## RLS and service-role boundary + +All service methods require the caller's workspace and all repositories scope +queries to it. Tests cover cross-workspace access to TikTok accounts, posts, +targets, jobs, media assets, and analytics. Unauthorized access is normalized +as not found/invalid and never yields another tenant's identifiers or metrics. + +Production must apply the additive Postgres migrations and RLS policies. A +service-role connection bypasses database RLS by design and therefore belongs +only in trusted backend workers; it must never be exposed to REST clients, +frontend code, SDKs, MCP clients, or n8n. SQLite tests validate service-layer +workspace enforcement but are not a substitute for a live Postgres RLS test. + +## MCP, n8n, SDKs, and frontend + +MCP exposes the existing generic tools, including `social.get_analytics`, and +enforces social scopes before executing actions. It contains no TikTok token or +publishing implementation. + +The n8n Social node discovers providers/accounts/capabilities dynamically, +supports single items, batches, per-item idempotency, and Continue On Fail, and +uses only the MediaRouter API key. Continue On Fail retains original item JSON +and adds a redacted normalized error. TikTok credentials never enter node +configuration or output. + +The TypeScript and Python SDKs keep `client.social` as the canonical surface +for provider discovery, accounts, posts, jobs, schedules, publish options, and +analytics. Typed TikTok metadata adds no credential properties. + +The frontend reads account and capability data from the backend. It supports +explicit publishing/analytics authorization, account reconnect/disconnect, +creator-specific privacy/options, validation errors, publish/schedule status +polling, retry display, and analytics rendering. It does not keep a static +TikTok capability table or provider token in browser storage. + +## Deployment and TikTok approval + +Required backend configuration: + +```dotenv +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +TIKTOK_REDIRECT_URI=https://api.example.com/v1/social/accounts/tiktok/callback +TIKTOK_DIRECT_POST_ENABLED=false +TIKTOK_UPLOAD_CHUNK_BYTES=10000000 +TIKTOK_REQUEST_TIMEOUT_SECONDS=60 +TIKTOK_PROCESSING_POLL_SECONDS=30 +SOCIAL_OAUTH_ENCRYPTION_KEY= +RUN_TIKTOK_INTEGRATION_TESTS=false +``` + +Production additionally requires a TikTok developer application with Login +Kit, Content Posting API Direct Post approval, the approved redirect/domain and +legal URLs, reviewed scope use, compliant UX/branding/content disclosures, and +approved test or production creators. The application must separately justify +`video.publish` and `video.list`. An unaudited client may be restricted to test +users or private posts. Environment variables configure MediaRouter; they do +not confer TikTok approval. + +The Docker contract exposes and starts the API on `PORT=7860`. Normal CI must +leave live tests disabled. A dedicated live test run additionally requires +`TIKTOK_TEST_ACCESS_TOKEN`, `TIKTOK_TEST_VIDEO_PATH`, explicit +`TIKTOK_TEST_ALLOW_PUBLISH=true`, and the granted-scope declaration. The smoke +test creates only a `SELF_ONLY` post because the official API does not provide +the implemented deletion capability. + +## Optional live integration + +Run only against a dedicated approved application and test creator: + +```bash +RUN_TIKTOK_INTEGRATION_TESTS=true \ +TIKTOK_TEST_ALLOW_PUBLISH=true \ +TIKTOK_TEST_GRANTED_SCOPES='user.info.basic video.publish video.list' \ +pytest -q tests/test_tiktok_production.py +``` + +The opt-in suite verifies account discovery, creator options, actual media +upload, terminal status, and analytics when `video.list` is granted. The normal +test suite never requires live credentials or provider traffic. + +## Known limitations and risks + +- Direct Post and live account behavior depend on TikTok review and account + eligibility; a configuration flag cannot validate either. +- No official deletion capability is implemented or advertised. +- No native TikTok scheduling is implemented or advertised. +- Photo/draft posting is outside the implemented capability set. +- Analytics is limited to the four official Display API video counters and + requires public IDs plus explicit `video.list` consent. +- TikTok provides no client idempotency key for Direct Post initialization, so + the ambiguous acceptance window is handled fail-closed and may require + operator investigation. +- Provider API changes, application review status, quotas, moderation, and + regional restrictions remain external operational risks. + +## Final Phase 4 audit (2026-07-31) + +| Area | Classification | Evidence / limitation | +|---|---|---| +| Phase 4A OAuth/account discovery implementation | PASS | Official v2 endpoints, single-use bound state, TokenService, duplicate prevention, mocked security tests | +| Phase 4B Direct Post implementation | PASS | Official init/chunk/status workflow, validation, durable recovery, mocked publishing tests | +| Analytics implementation | PASS | Official `video/query`, explicit `video.list`, normalized persistence tests | +| Token/output/log/audit redaction | PASS | Recursive schema/persistence/log/n8n redaction tests | +| Service-layer workspace isolation | PASS | Account/post/target/job/asset/analytics cross-workspace tests | +| PostgreSQL RLS runtime | NOT VERIFIED | No live Postgres/Supabase service-role/RLS environment supplied | +| OAuth live consent and invalid-code behavior | NOT VERIFIED | Mocked contract/security coverage exists; no dedicated live TikTok credentials supplied | +| Idempotency/restart/retry logic | PASS | Same/different key, bounded retry matrix, timeout and fail-closed recovery coverage | +| MCP contract/scope enforcement | PASS | Generic tool registration and pre-execution scope test | +| n8n | PASS | 6 tests, TypeScript lint, build, and package dry-run | +| TypeScript SDK | PASS | 6 tests, type check, build, and package dry-run | +| Python SDK | PASS | 6 tests and Python compilation | +| Frontend | PASS | 70 tests, zero-warning lint, type check, and optimized production build | +| Backend full suite in target Python 3.10/Linux | NOT VERIFIED | Current host is Android/Python 3.14 and cannot resolve/build the pinned production dependency set | +| Live TikTok publish/status/analytics | NOT VERIFIED | Opt-in suite exists; credentials and application approval were not supplied | +| Docker image/runtime health check | NOT VERIFIED | Docker is unavailable on the validation host; Dockerfile uses port 7860 | +| YouTube/Meta and media-tool runtime regression | WARNING | Shared code is compilation/client-test covered, but full backend and Docker runtime gates remain unavailable on this host | + +There are no known code-level Phase 4 blockers from the checks that completed. +However, Phase 4 must **not** be called production-ready or fully certified +until the critical NOT VERIFIED items—live TikTok, target-runtime backend, +Postgres RLS, and Docker—pass in the deployment environment. diff --git a/docs/social-tiktok-publishing.md b/docs/social-tiktok-publishing.md new file mode 100644 index 0000000000000000000000000000000000000000..c6626bb5f52ac59839266910595e3edd8b3eb08e --- /dev/null +++ b/docs/social-tiktok-publishing.md @@ -0,0 +1,279 @@ +# TikTok publishing (Phase 4B) + +Phase 4B adds production-oriented TikTok video Direct Post support to the +existing MediaRouter social pipeline. It does not add a TikTok-specific REST, +MCP, SDK, n8n, job, scheduler, token, or media architecture. Analytics and the +final audit are implemented separately by Phase 4C; see +[TikTok production readiness](social-tiktok-production-readiness.md). + +The implementation was checked on 2026-07-31 against TikTok's official +documentation: + +- [Content Posting API overview](https://developers.tiktok.com/doc/content-posting-api-get-started) +- [Direct Post](https://developers.tiktok.com/doc/content-posting-api-reference-direct-post) +- [Query Creator Info](https://developers.tiktok.com/doc/content-posting-api-reference-query-creator-info) +- [Media transfer guide](https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide) +- [Get post status](https://developers.tiktok.com/doc/content-posting-api-reference-get-video-status) +- [Content Posting API UX guidelines](https://developers.tiktok.com/doc/content-posting-api-ux-guidelines) + +Only official `open.tiktokapis.com` Content Posting endpoints and TikTok-issued +HTTPS upload URLs are used. MediaRouter does not scrape TikTok and does not use +legacy or unofficial endpoints. + +## Architecture and workflow + +```text +frontend / REST / MCP / SDK / n8n + | + SocialService + | + durable SocialJob + scheduler + | + SocialPublisher + | + TikTokProvider + | + creator_info/query -> video/init -> chunked PUT -> status/fetch +``` + +For every publish, the backend: + +1. verifies the workspace owns the account, post, target, job, and media asset; +2. obtains the token only through TokenService and checks the explicitly + granted `video.publish` scope; +3. resolves the existing MediaAsset/MediaVariant and runs FFprobe validation; +4. queries current creator privacy, interaction, and duration options; +5. initializes one `FILE_UPLOAD` Direct Post session; +6. streams sequential byte ranges to the trusted TikTok upload URL; +7. stores the `publish_id` and recovery state in encrypted SocialJob state; +8. polls TikTok publish status until it is published or fails; and +9. persists only safe external identifiers and provider status metadata. + +TikTok starts processing after the last upload chunk. There is no fabricated +second “publish” endpoint in the adapter. + +## Application approval and configuration + +Direct Post is disabled by default: + +```dotenv +TIKTOK_CLIENT_KEY= +TIKTOK_CLIENT_SECRET= +TIKTOK_REDIRECT_URI=https://api.example.com/v1/social/accounts/tiktok/callback +TIKTOK_DIRECT_POST_ENABLED=false +TIKTOK_UPLOAD_CHUNK_BYTES=10000000 +TIKTOK_REQUEST_TIMEOUT_SECONDS=60 +TIKTOK_PROCESSING_POLL_SECONDS=30 +SOCIAL_OAUTH_ENCRYPTION_KEY= +``` + +`TIKTOK_CLIENT_SECRET`, OAuth tokens, upload URLs, and +`SOCIAL_OAUTH_ENCRYPTION_KEY` are backend-only. Do not use `NEXT_PUBLIC_` +variables or n8n credentials for them. + +Before setting `TIKTOK_DIRECT_POST_ENABLED=true`, the operator must: + +1. add the TikTok Content Posting API product to the same developer app used + by Login Kit; +2. request and receive Direct Post access and the `video.publish` scope; +3. provide the domain, redirect, privacy policy, terms, UX, and video evidence + required by TikTok review; +4. use approved sandbox/test creators during development; and +5. verify the app's audit status and production posting restrictions in the + TikTok developer portal. + +An unaudited client is restricted by TikTok (including private-only posting and +test-user limitations). Setting the flag does not grant provider approval. The +backend still rejects missing scope or creator options with a structured error. + +A normal account connection continues to request only `user.info.basic`. The +user or client must explicitly start OAuth with +`authorization_purpose=publishing`; only that flow requests `video.publish`. +The frontend exposes this as **Enable publishing**. Scope elevation is never +silent. + +## Capabilities + +When `TIKTOK_DIRECT_POST_ENABLED=false`, TikTok remains an OAuth/account +foundation and all publishing flags are false. When it is true, backend +capabilities advertise: + +- video and video upload; +- Direct Post; +- provider status reconciliation; and +- MediaRouter scheduling (`scheduled_publish=true`, + `native_scheduling=false`). + +Image/photo posting, draft upload/inbox posting, native scheduling, and +deletion remain unavailable. Phase 4C adds official Display API video analytics +behind explicit `video.list` authorization. MediaRouter never reports deletion +success because the current Direct Post workflow has no supported deletion +endpoint. +The frontend, MCP, SDKs, and n8n use these backend capability values rather +than keeping a separate TikTok capability table. + +## Typed post metadata and creator options + +The generic target accepts a typed `tiktok` object: + +```json +{ + "social_account_id": "account-id", + "caption": { "caption": "Rendered by MediaRouter" }, + "tiktok": { + "title": "Rendered by MediaRouter", + "privacy_level": "SELF_ONLY", + "disable_comment": false, + "disable_duet": false, + "disable_stitch": false, + "brand_content_toggle": false, + "brand_organic_toggle": false, + "is_aigc": false, + "music_usage_confirmed": true + } +} +``` + +Before initialization the UI and automation clients can call: + +```http +GET /v1/social/accounts/{account_id}/publish-options +``` + +This returns current creator privacy choices, disabled interactions, maximum +duration, and safe creator display data. The privacy value must be one of the +returned choices. MediaRouter forces an interaction to remain disabled when +creator settings require it, rejects paid partnership content with non-public +visibility, validates the cover timestamp, and requires explicit Music Usage +Confirmation. The confirmation is persisted as MediaRouter policy evidence +but is not sent as an undocumented TikTok API field. + +TikTok titles are limited to 2,200 UTF-16 code units. Paid partnership, +own-brand promotion, and AI-generated-content declarations are explicit +booleans; no value is inferred from the caption. + +## Media requirements + +MediaRouter validates the actual file and fresh FFprobe output before any +provider initialization. Supported Direct Post video is restricted to: + +- MIME/container: MP4, MOV, or WebM; +- video codec: H.264, H.265/HEVC, VP8, or VP9; +- frame rate: 23 through 60 FPS; +- dimensions: width and height each 360 through 4096 pixels; +- duration: positive and no more than the lesser of 10 minutes and the current + creator-specific maximum; +- size: positive and no more than the lesser of 4 GB and + `MAX_UPLOAD_SIZE`; and +- optional audio: AAC, MP3, Opus, or Vorbis when an audio stream is present. + +TikTok's current contract constrains dimensions rather than publishing a +separate allowed-aspect-ratio list. MediaRouter validates a finite positive +aspect ratio but does not invent narrower ratios. It never silently edits the +source. An incompatible source returns `SOCIAL_MEDIA_INVALID`; callers may use +the existing FFmpeg/template pipeline (for example the existing `tiktok_hd` +template), register the resulting variant, and publish that owned variant. + +The provider never reads a large video into one application-wide buffer. +`FILE_UPLOAD` uses sequential chunks. Normal chunks are 5–64 MB, the final +chunk can be up to 128 MB, files below 5 MB are sent whole, and the plan is +limited to 1,000 chunks. Intermediate upload responses are `206`; completion +is `201`. + +## Jobs, retries, status, and idempotency + +The shared state machine remains: + +```text +QUEUED -> PREPARING -> PROCESSING -> UPLOADING -> PUBLISHING -> PUBLISHED + \-> RETRYING --------------/ + \-> FAILED +``` + +TikTok statuses normalize as follows: + +| TikTok status | MediaRouter status | +|---|---| +| `PROCESSING_UPLOAD`, `PROCESSING_DOWNLOAD`, `SEND_TO_USER_INBOX` | `PROCESSING` | +| `PUBLISH_COMPLETE` | `PUBLISHED` | +| `FAILED` | `FAILED` | +| unknown or invalid publish ID | `UNAVAILABLE` | + +Provider processing polls do not increment the publish attempt count. A job +uses one logical idempotency key across retries and restarts. The post and job +unique constraints make repeated identical requests return the existing +logical publish; reusing the key with a different payload returns +`SOCIAL_IDEMPOTENCY_CONFLICT`. + +Before initialization, the worker durably records an encrypted +`tiktok_init_started` marker. After TikTok returns a `publish_id`, it stores the +ID, trusted upload URL, byte plan, and uploaded offset in the same encrypted +state. If the worker restarts, it queries status and resumes that session. If +TikTok may have accepted initialization but the ID could not be made durable, +MediaRouter refuses to initialize again. This fail-closed case requires +operator investigation but guarantees that recovery does not create a second +post when TikTok offers no client idempotency key or lookup-by-client-key API. + +HTTP `429`, `500`, `502`, `503`, `504`, and transport timeouts are transient. +Retries use the existing bounded backoff and job attempt limit. Chunk delivery +timeouts and 5xx responses query the official status endpoint before another +PUT. Invalid metadata/media, permission errors, unsupported operations, and +ordinary 400 responses fail without retry. Authentication failure goes through +the existing one-refresh TokenService/OAuthService path; an unusable refresh +token becomes `SOCIAL_REAUTH_REQUIRED`. There is no infinite retry path. + +## Scheduling + +TikTok Direct Post currently has no implemented native scheduling field. +MediaRouter stores the requested aware timestamp in canonical UTC, preserves +the supplied IANA timezone, and queues the same Direct Post workflow when its +shared scheduler claims the row. Clients must interpret +`native_scheduling=false` literally. + +## Error normalization + +| Condition | MediaRouter error | +|---|---| +| expired/revoked authentication | `SOCIAL_REAUTH_REQUIRED` | +| missing scope/creator permission | `SOCIAL_PERMISSION_DENIED` | +| provider rate limit | `SOCIAL_RATE_LIMITED` | +| invalid file/container/codec/range | `SOCIAL_MEDIA_INVALID` | +| transient provider/network failure | retryable `SOCIAL_PROVIDER_UNAVAILABLE` | +| invalid metadata/spam-policy rejection | `SOCIAL_PUBLISH_FAILED` | +| application not approved/enabled | `SOCIAL_CAPABILITY_UNSUPPORTED` or `SOCIAL_PERMISSION_DENIED` | + +Provider response bodies, tokens, client secrets, upload URLs, and refresh +tokens are not returned in errors, logs, audit events, jobs, SDK models, MCP +results, n8n output, or frontend storage. + +## REST, MCP, SDK, n8n, and frontend + +All transports keep the existing `/v1/social` contract. The MCP tools +`social.list_providers`, `social.get_capabilities`, `social.list_accounts`, +`social.create_post`, `social.publish_post`, `social.schedule_post`, and +`social.get_job` route through SocialService with no TikTok credentials or +provider business logic. + +TypeScript and Python expose typed TikTok target metadata and publish options +through `client.social`. The n8n Social node keeps generic create/publish/ +schedule operations, supports item-by-item execution, batch input, +Continue On Fail, and idempotency, and obtains providers/accounts dynamically. +Its connection operation has an explicit Publishing authorization purpose. +The frontend lists only connected accounts whose backend capabilities allow +publishing, fetches creator options per TikTok account, renders current privacy +and interaction controls, supports draft/now/MediaRouter schedule modes, polls +jobs, and displays normalized errors. + +## Verification boundary + +Mocked tests cover media and metadata validation, official endpoint shapes, +streamed upload, status normalization, error classes, approval gating, scope +authorization, durable duplicate prevention, the shared job lifecycle, +idempotency, and workspace isolation. Existing YouTube/Meta code paths are not +replaced. + +Live TikTok OAuth and posting require a dedicated approved app and test creator +and are not part of normal CI. Phase 4B implementation or passing mock tests +must not be described as TikTok production certification. Phase 4C's audit +retains live provider, target-runtime backend, Postgres RLS, and Docker checks +as NOT VERIFIED until they run in an eligible deployment environment. diff --git a/docs/social-youtube-production-readiness.md b/docs/social-youtube-production-readiness.md new file mode 100644 index 0000000000000000000000000000000000000000..8822679d0238083d3048b8f5289d5c3554f80896 --- /dev/null +++ b/docs/social-youtube-production-readiness.md @@ -0,0 +1,82 @@ +# YouTube Phase 2 production-readiness report + +## Overall status: NOT VERIFIED + +The implementation and mocked/provider-independent validation are complete, +but this Termux Android host cannot install the project's required native +backend dependencies for CPython 3.14 and has no Docker daemon. No staging +Google credentials were supplied. Do not treat this report as a production +approval until backend tests, a Docker build/start on `PORT=7860`, and the +opt-in Google test-channel workflow have been run in a supported CI/staging +environment. + +| Area | Status | Evidence / note | +|---|---|---| +| Provider adapter / capabilities | PASS | `app/social/providers/youtube.py` implements official OAuth, channel lookup, refresh/revoke, resumable upload, status, deletion, and video statistics; it exposes only implemented video upload/publish/status/delete and channel-metadata capabilities. | +| Google environment | PASS | Backend-only Google client variables and upload/concurrency/poll configuration are documented in `.env.example` and `docs/social-youtube.md`. | +| OAuth / PKCE / state | PASS | Reuses the Phase 1 encrypted, expiring, single-use state implementation; exchange sends PKCE verifier. | +| Token handling | PASS | TokenService remains the only token reader; OAuthService refreshes before expiry and marks failed refreshes reauthorization-required. | +| Channel identity | PASS | `channels.list(mine=true)` persists stable channel ID, title, handle/custom URL, avatar, and safe metadata; workspace duplicate connections upsert. | +| Media validation | PASS | Additive `social_media_assets` binds a workspace to a MediaRouter output; worker validates readability/type/container/codec/duration/dimensions/size using shared FFprobe/validator services. | +| Typed metadata | PASS | `YouTubePostMetadata` constrains title, description, tags, category, privacy, schedule, notification, and required audience declaration. | +| Resumable upload | PASS | File-backed chunks, session initialization/query/resume, retryable transport handling, bounded upload concurrency, and encrypted session state are implemented. | +| Idempotency / crash recovery | PASS | Stable per-target job key plus durable video ID/session reconciliation prevent duplicate inserts after a worker failure. | +| Job processing / status | PASS | Existing state machine and scheduler are used; `videos.list` gates final PUBLISHED status. | +| Scheduling | PASS | Durable MediaRouter UTC scheduler preserves IANA timezone. Explicit native `scheduled_publish_at` is validated and sent only when requested. | +| Deletion | PASS | Tenant-scoped post deletion calls `videos.delete` for persisted external video IDs. | +| Analytics | PASS | Authorized video statistics are normalized into `social_post_metrics`; channel analytics is explicitly unavailable without a new authorization flow. | +| Error/retry normalization | PASS | Authentication, permission, quota, rate, metadata, network, and transient provider failures use Social error classes and existing retry policy. | +| Audit / observability | PASS | Connect/reauthorize/create/publish-start/published/failed/deleted events and structured provider/upload/token events omit credentials. | +| REST / frontend | PASS | Existing `/v1/social` routes, 202 job publication, registered output selection, account reconnect/disconnect, typed YouTube compose, schedule, job/error state, and post analytics are shared. | +| MCP | PASS | Existing generic SocialService MCP tools require no YouTube-specific business path. | +| TypeScript/Python SDK | PASS | `client.social` stays canonical; TypeScript and Python expose YouTube metadata/registered-media helpers. | +| n8n | PASS | Existing Social node dynamically uses backend accounts/providers and adds a typed YouTube post operation; no Google credential is stored in n8n. | +| Migration | PASS | Additive `0004_youtube_media_assets.sql` creates workspace output bindings and confidential resumable job state. | +| Security | PASS | RLS-scoped repositories, encrypted tokens/session state, no credential-bearing views, single-use state, typed input, and cross-workspace account/post/job/asset tests are present. | +| Backend source validation | PASS | `python -m compileall -q app tests sdk/python/media_platform` completed successfully. | +| Backend unit tests / lint / type check | BLOCKER | `pytest` cannot import `pydantic` locally; Android/CPython 3.14 lacks installable wheels/build support for required `pydantic-core`, `cryptography`, `orjson`, and `asyncpg`. Run the mocked suite, Python lint/type checks, and startup smoke test in supported CI. | +| Frontend | PASS | `lint`, `typecheck`, `test` (17 tests), and production `next build` completed successfully. | +| TypeScript SDK | PASS | `build`, `lint`, and `test` (3 tests) completed successfully. | +| Python SDK | PASS | `PYTHONPATH=sdk/python .venv/bin/pytest -q sdk/python/tests` passed (3 tests); Python compilation passed. | +| n8n | PASS | `build`, `lint`, `test` (3 tests), and package dry-run/pack check completed successfully. | +| Docker / Hugging Face | NOT VERIFIED | Code retains `PORT=7860` and no provider secrets are frontend-bound, but Docker is not installed on this host. Build the image and run the backend smoke test in CI or staging. | +| Live Google integration | NOT VERIFIED | No staging credentials or test channel were supplied. Opt-in guard: `RUN_YOUTUBE_INTEGRATION_TESTS=true`. | + +## Files created + +- `app/social/providers/youtube.py` +- `app/social/schemas/youtube.py`, `assets.py` +- `app/social/repositories/assets.py` +- `app/social/services/media_asset_service.py` +- `app/social/migrations/0004_youtube_media_assets.sql` +- `tests/test_youtube_provider.py`, `tests/test_youtube_live.py` +- `docs/social-youtube.md` + +## Files materially modified + +Social models, job/account/post repositories, OAuth/token orchestration, publisher, analytics, container, Social REST routes, policy, configuration, frontend social features/API hooks, TypeScript/Python SDKs, n8n Social node, `.env.example`, `README.md`, and `docs/social-automation.md` are updated as part of the shared implementation. + +## API and operational changes + +- Existing `/v1/social` provider, account, post, job, schedule, delete, and analytics routes now execute YouTube through the shared SocialService path. `POST /v1/social/posts/{post_id}/publish` remains `202 Accepted` and returns job views. +- `GET`/`POST /v1/social/assets` add tenant-scoped registration/listing of existing MediaRouter outputs. The generic MCP and SDK Social surfaces expose the same operation; no transport owns Google business logic. +- The only schema change is additive migration `0004_youtube_media_assets.sql`: `social_media_assets` and encrypted per-job provider state. Apply `0001` through `0004` in order. +- Required backend environment variables: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `SOCIAL_OAUTH_REDIRECT_BASE_URL`, `SOCIAL_OAUTH_ENCRYPTION_KEY`, `YOUTUBE_UPLOAD_CHUNK_BYTES`, `YOUTUBE_MAX_CONCURRENT_UPLOADS`, `YOUTUBE_REQUEST_TIMEOUT_SECONDS`, and `YOUTUBE_PROCESSING_POLL_SECONDS`. + +## Validation record + +- PASS — `python -m compileall -q app tests sdk/python/media_platform` +- PASS — frontend `lint`, `typecheck`, `test`, and production build +- PASS — TypeScript SDK `build`, `lint`, and test suite +- PASS — Python SDK test suite and compilation +- PASS — n8n `build`, `lint`, test suite, and package dry-run +- BLOCKER — backend pytest stopped before collection with `ModuleNotFoundError: pydantic`; this host cannot build the required native dependency stack. +- NOT VERIFIED — Docker image/start on `PORT=7860`; `docker` is unavailable on this host. +- NOT VERIFIED — opt-in live OAuth/channel/upload/processing/analytics/delete lifecycle; no staging Google credentials/test channel. + +## Unresolved deployment issues + +- Apply social migrations `0001` through `0004` to production Supabase/Postgres before enabling writes. +- Provision Google OAuth consent, exact callback URI, API quota, staging test user/channel, and encrypted token storage/Vault. +- The existing repository’s native general asset system is not present; Phase 2 adds the minimal secure binding to existing MediaRouter output files. Use durable output storage/retention appropriate to scheduled publishing. +- Use a supported Linux/macOS CI or staging runner to install backend dependencies, run the full mocked backend suite, build Docker, and verify `PORT=7860` before release. diff --git a/docs/social-youtube.md b/docs/social-youtube.md new file mode 100644 index 0000000000000000000000000000000000000000..0aaa1303f1f590b6d8d9c5af8e6e4fdece094013 --- /dev/null +++ b/docs/social-youtube.md @@ -0,0 +1,141 @@ +# YouTube publishing + +MediaRouter Phase 2 implements YouTube as the first production provider through the shared SocialService path: + +```text +REST / MCP / SDK / n8n / Next.js → SocialService → SocialPublisher → YouTubeProvider → YouTube Data API v3 +``` + +No client integration talks to Google directly and no Google credential is sent to the browser, MCP, SDK, or n8n. + +## Google Cloud setup + +1. Create or choose a Google Cloud project owned by the deployment operator. +2. Enable **YouTube Data API v3** for that project. +3. Configure an OAuth consent screen. While the app is in Testing, add every staging channel owner under **Test users**. Production use must complete Google’s required verification/publishing process for the selected scope. +4. Create a **Web application** OAuth client. +5. Add this exact authorized redirect URI (replace the origin): + + ```text + https://api.example.com/v1/social/accounts/youtube/callback + ``` + + The origin is `SOCIAL_OAUTH_REDIRECT_BASE_URL`; it must be HTTPS except for localhost development. It is backend-owned and cannot be replaced by a browser request. +6. Configure only backend secrets: + + ```env + GOOGLE_CLIENT_ID=... + GOOGLE_CLIENT_SECRET=... + SOCIAL_OAUTH_REDIRECT_BASE_URL=https://api.example.com + SOCIAL_OAUTH_ENCRYPTION_KEY= + YOUTUBE_UPLOAD_CHUNK_BYTES=8388608 + YOUTUBE_MAX_CONCURRENT_UPLOADS=2 + YOUTUBE_REQUEST_TIMEOUT_SECONDS=60 + YOUTUBE_PROCESSING_POLL_SECONDS=30 + ``` + +Do not put `GOOGLE_CLIENT_SECRET` in Vercel, `NEXT_PUBLIC_*`, n8n credentials, SDK options, source control, or logs. `GOOGLE_CLIENT_ID` is also kept backend-side because it is coupled to the configured callback policy. + +## OAuth and accounts + +Click **Connect YouTube** in the Social Accounts UI or call `POST /v1/social/accounts/youtube/connect`. MediaRouter requests exactly: + +```text +https://www.googleapis.com/auth/youtube.upload +``` + +The authorization request includes `access_type=offline` and PKCE S256. State is random, single-use, expiring, provider-bound, workspace-bound, user-bound, and bound to the exact backend callback URI. The callback exchanges the code server-side, stores credentials using TokenService (Supabase Vault when enabled, otherwise Fernet encryption), and discovers the authenticated channel through `channels.list(mine=true)`. + +The stable external identity is the YouTube channel ID, never an email. Reauthorizing an already connected channel refreshes its credentials and metadata instead of creating a duplicate connection in that workspace. A channel can still be connected independently in a different workspace. + +## Preparing media + +Social publishing uses registered outputs from the existing MediaRouter media pipeline. After a normal FFmpeg/template/media operation has returned a request ID and filename, register it once: + +```http +POST /v1/social/assets +Content-Type: application/json + +{"request_id":"","filename":"video.mp4"} +``` + +This additive, tenant-scoped asset reference proves the file originated from MediaRouter and lets the worker resolve it without accepting arbitrary paths or URLs. It stores no copy of the video. Outputs still follow the configured cleanup lifecycle; register and publish before expiry or use durable output storage. + +Before every upload MediaRouter verifies workspace ownership, file readability, video media type, file size, FFprobe container, codec, duration, and dimensions. It never silently transcodes or modifies a source. Create a compatible MediaRouter variant first when validation reports an unsupported container or codec. + +## Typed post metadata + +YouTube targets use a `youtube` object, not arbitrary provider JSON: + +```json +{ + "media_asset_id": "", + "publish_mode": "draft", + "targets": [{ + "social_account_id": "", + "youtube": { + "title": "My video", + "description": "Description", + "tags": ["media", "automation"], + "category_id": "22", + "privacy_status": "unlisted", + "made_for_kids": false, + "notify_subscribers": true + } + }] +} +``` + +`made_for_kids` is required; MediaRouter never guesses a policy-sensitive audience declaration. Categories are constrained to the YouTube Data API IDs implemented by the schema. Native `scheduled_publish_at`, when supplied, must be a future UTC-offset timestamp and uses `private` visibility as required by YouTube. + +## Publishing, status, scheduling, and deletion + +`POST /v1/social/posts/{post_id}/publish` accepts an `Idempotency-Key` and returns `202 Accepted` with durable job representations. The scheduler/worker performs: + +```text +QUEUED → PREPARING → PROCESSING → UPLOADING → PUBLISHING → PUBLISHED +``` + +Uploads use the official resumable `videos.insert` protocol. Video bytes are read from disk in configurable chunks; large files are never loaded in full. The resumable session URI is encrypted in durable job state and is excluded from REST, MCP, SDK, n8n, and frontend payloads. Interrupted uploads query/resume the session where possible. + +As soon as YouTube returns a video ID, MediaRouter stores the external ID and watch URL before status reconciliation. If a process dies after Google accepted an upload, a recovered job reconciles that ID/session rather than uploading again. A timeout is never interpreted as a failed upload. + +`videos.list` reconciles `processing`, `published`, `failed`, `unavailable`, and `deleted` outcomes. Upload completion alone does not mark a job published. To schedule through MediaRouter, create a post with `publish_mode: "schedule"`, an aware future timestamp, and IANA timezone; the durable scheduler dispatches it at the canonical UTC instant while retaining the original timezone. `scheduled_publish_at` is the explicitly requested native YouTube scheduling option. + +Deleting an externally published Social Post invokes the authorized YouTube `videos.delete` call before removing the local post. Tenant-scoped account/post checks prevent cross-workspace deletion. + +## Retries, quotas, and analytics + +Network failures and 429/500/502/503/504 are retryable with the existing bounded exponential backoff. A 401 performs one refresh and one retry; another unauthorized result marks the account `reauth_required` and returns `SOCIAL_REAUTH_REQUIRED`. Permission errors normalize to `SOCIAL_PERMISSION_DENIED`; generic rate limits normalize to `SOCIAL_RATE_LIMITED`; quota reasons such as `quotaExceeded` normalize to `SOCIAL_PROVIDER_QUOTA_EXCEEDED`. + +`YOUTUBE_MAX_CONCURRENT_UPLOADS` limits worker upload concurrency. Google API quota remains a Google Cloud project setting; monitor it in Cloud Console. + +Post analytics use `videos.list(part=statistics,snippet,status)` and persist only returned values (`viewCount`, `likeCount`, and `commentCount`) in `social_post_metrics`, retaining `raw_metrics`. Channel-level YouTube Analytics reporting is intentionally **unavailable** because it requires additional authorization not included in the upload flow. MediaRouter never silently adds an analytics scope. + +## Troubleshooting + +| Symptom | Action | +|---|---| +| `SOCIAL_PROVIDER_UNAVAILABLE` during connect | Set both Google client variables and a valid backend redirect base URL. | +| Google shows redirect mismatch | Copy the exact callback URI from this document into the OAuth client, including `/v1/social/accounts/youtube/callback`. | +| No channel discovered | Ensure the Google user owns or manages a YouTube channel and is a consent-screen test user when the app is in Testing. | +| `SOCIAL_REAUTH_REQUIRED` | Use **Reconnect** and complete Google consent; do not paste a token into MediaRouter. | +| `SOCIAL_MEDIA_INVALID` | Register a readable MediaRouter video output and create a compatible FFmpeg/template variant. | +| Job remains `publishing` | YouTube is processing the video; the worker will poll at `YOUTUBE_PROCESSING_POLL_SECONDS`. | +| `SOCIAL_PROVIDER_QUOTA_EXCEEDED` | Wait for/reset or increase the Google Cloud API quota; retries cannot override provider quota. | + +## Live verification + +Normal CI mocks Google. The optional destructive integration test is enabled only when all of the following are supplied for a staging channel: + +```env +RUN_YOUTUBE_INTEGRATION_TESTS=true +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +YOUTUBE_LIVE_TEST_ACCESS_TOKEN=... +YOUTUBE_LIVE_TEST_REFRESH_TOKEN=... # optional: also verifies refresh +YOUTUBE_LIVE_TEST_MEDIA_PATH=/absolute/path/to/staging-video.mp4 +YOUTUBE_LIVE_TEST_DELETE=true +``` + +The token must come from a human-completed staging OAuth connection. The test discovers the channel, performs the resumable upload, reconciles initial processing/published state, retrieves video statistics, and deletes its test video in `finally`. It will skip rather than upload when cleanup is not explicitly enabled. No staging Google credentials were supplied with this change, so live OAuth/upload/status/analytics/delete verification is **NOT VERIFIED**. diff --git a/main.py b/main.py index 400518a697b45e3fda4548dccfeb365f591624db..c08ea71d8de453ff88db0fc78b5a960058f0ebff 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ from fastapi.responses import JSONResponse, ORJSONResponse from starlette.middleware.base import RequestResponseEndpoint from starlette.responses import Response -from app.api import api_keys, audio, health, image, media, probe, templates, video, whisper, ytdlp +from app.api import api_keys, audio, health, image, media, probe, social, templates, video, whisper, ytdlp from app.container import build_container from app.core.config import Settings, get_settings from app.core.exceptions import MediaAPIError @@ -20,6 +20,7 @@ from app.core.response import ErrorBody, ErrorResponse from app.mcp.server import create_mcp_server from app.security.middleware import APIKeyAuthenticationMiddleware from app.workers.cleanup_worker import CleanupWorker +from app.social.workers.scheduler import SocialSchedulerWorker configure_logging() logger = get_logger(__name__) @@ -40,6 +41,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: active_settings.ensure_directories() container = build_container(active_settings) cleanup_worker = CleanupWorker(container.cleanup, active_settings.cleanup_interval_seconds) + social_worker = SocialSchedulerWorker(container.social, active_settings.social_scheduler_interval_seconds) mcp_server = create_mcp_server(container) mcp_http_app = mcp_server.streamable_http_app() @@ -49,8 +51,11 @@ def create_app(settings: Settings | None = None) -> FastAPI: application.state.mcp_server = mcp_server await container.security_database.initialize() await container.api_keys.ensure_bootstrap_admin() + await container.social.initialize() async with mcp_server.session_manager.run(): await cleanup_worker.start() + if active_settings.social_enabled and active_settings.social_worker_enabled: + await social_worker.start() logger.info( "media API started", extra={"version": active_settings.app_version, "port": active_settings.port}, @@ -58,7 +63,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: try: yield finally: + await social_worker.stop() await cleanup_worker.stop() + await container.social.close() await container.security_database.close() logger.info("media API stopped") @@ -174,6 +181,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: application.include_router(ytdlp.router) application.include_router(whisper.router) application.include_router(templates.router) + application.include_router(social.router) application.mount("/mcp", mcp_http_app, name="mcp") @application.get("/", tags=["public"], include_in_schema=False) @@ -203,7 +211,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: "description": "MediaRouter API key", } schema["security"] = [{"APIKeyBearer": []}] - for path in ("/health",): + # The provider callback cannot send an API key. It is authenticated by + # the random, expiring, single-use OAuth state stored server-side. + for path in ("/health", "/v1/social/accounts/{provider}/callback"): for operation in schema.get("paths", {}).get(path, {}).values(): if isinstance(operation, dict): operation["security"] = [] diff --git a/requirements.txt b/requirements.txt index 5234246a930e2e4d64a6911af85af1036c7b1a0a..c2ab5f6345664021ba9532f70c52b27b974b187b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,5 @@ mcp==1.28.1 PyYAML==6.0.3 SQLAlchemy==2.0.43 aiosqlite==0.21.0 +asyncpg==0.30.0 +cryptography==46.0.3 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index c413662d0e688fb0fa08911c8bad59f4eb4b836d..eca38ca8b43b39233f2a20a7c3608f256f422233 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -33,6 +33,23 @@ async def test_mcp_registers_all_tools_resources_and_prompts(settings) -> None: + SYSTEM_TOOLS + TEMPLATE_TOOLS ) + expected_tools.update( + { + "social.list_providers", + "social.get_capabilities", + "social.list_media_assets", + "social.register_media_asset", + "social.list_accounts", + "social.get_account", + "social.create_post", + "social.publish_post", + "social.schedule_post", + "social.cancel_post", + "social.get_post", + "social.get_job", + "social.get_analytics", + } + ) assert tools == expected_tools resources = {str(resource.uri) for resource in await server.list_resources()} diff --git a/tests/test_meta_production.py b/tests/test_meta_production.py new file mode 100644 index 0000000000000000000000000000000000000000..18780ece0d025ea09d959e9d6576469df5b4a528 --- /dev/null +++ b/tests/test_meta_production.py @@ -0,0 +1,268 @@ +"""Phase 3C unit coverage for Meta analytics and boundary hardening. + +All Graph calls use MockTransport. Live tests remain opt-in so normal CI never +requires a Page, professional account, browser consent, or Meta credentials. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from app.container import build_container +from app.core.config import Settings +from app.social.domain.errors import SocialReauthRequiredError +from app.social.models import SocialAccount, SocialJob, SocialPost, SocialPostTarget +from app.social.providers.facebook import FacebookProvider +from app.social.providers.instagram import InstagramProvider +from app.social.schemas.accounts import SocialAccountView +from app.social.schemas.jobs import SocialJobView + + +def meta_settings(tmp_path: Path) -> Settings: + return Settings( + _env_file=None, + auth_enabled=False, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}", + social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}", + social_auto_migrate=True, + social_worker_enabled=False, + social_oauth_encryption_key="test-only-encryption-material", + meta_app_id="meta-app-id", + meta_app_secret="meta-app-secret", + temp_dir=tmp_path / "temp", + output_dir=tmp_path / "outputs", + cleanup_interval_seconds=3600, + whisper_model="tiny", + ) + + +@pytest.fixture +async def meta_container(tmp_path: Path): + container = build_container(meta_settings(tmp_path)) + await container.social.initialize() + try: + yield container + finally: + await container.social.close() + await container.security_database.close() + + +async def test_facebook_page_metrics_use_v25_bearer_auth_and_normalize(tmp_path: Path) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v25.0/page-post-id" + assert request.headers["authorization"] == "Bearer token-that-must-not-enter-url" + assert "access_token" not in request.url.query.decode() + return httpx.Response( + 200, + json={ + "created_time": "2026-07-31T00:00:00+0000", + "insights": { + "data": [ + {"name": "post_impressions", "values": [{"value": 42}]}, + {"name": "post_video_views", "values": [{"value": 11}]}, + ] + }, + "reactions": {"summary": {"total_count": 7}}, + "comments": {"summary": {"total_count": 3}}, + "shares": {"count": 2}, + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = FacebookProvider(meta_settings(tmp_path), http_client=client) + try: + result = await provider.get_metrics({"access_token": "token-that-must-not-enter-url"}, "page-post-id") + finally: + await client.aclose() + assert result["status"] == "available" + assert result["impressions"] == 42 + assert result["views"] == 11 + assert result["likes"] == 7 + assert result["comments"] == 3 + assert result["shares"] == 2 + + +async def test_instagram_reel_metrics_are_media_type_specific(tmp_path: Path) -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + assert request.headers["authorization"] == "Bearer meta-token" + if request.url.path == "/v25.0/ig-media-id": + return httpx.Response( + 200, + json={ + "media_product_type": "REELS", + "media_type": "VIDEO", + "timestamp": "2026-07-31T00:00:00+0000", + "permalink": "https://www.instagram.com/reel/example/", + }, + ) + assert request.url.path == "/v25.0/ig-media-id/insights" + assert parse_qs(request.url.query.decode())["metric"] == ["views,reach,likes,comments,shares,saved"] + return httpx.Response( + 200, + json={ + "data": [ + {"name": "views", "values": [{"value": 100}]}, + {"name": "reach", "values": [{"value": 80}]}, + {"name": "likes", "values": [{"value": 20}]}, + {"name": "comments", "values": [{"value": 4}]}, + {"name": "shares", "values": [{"value": 2}]}, + {"name": "saved", "values": [{"value": 9}]}, + ] + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = InstagramProvider(meta_settings(tmp_path), http_client=client) + try: + result = await provider.get_metrics({"access_token": "meta-token"}, "ig-media-id") + finally: + await client.aclose() + assert len(requests) == 2 + assert result["status"] == "available" + assert result["views"] == 100 + assert result["shares"] == 2 + assert result["url"] == "https://www.instagram.com/reel/example/" + + +async def test_meta_graph_authentication_error_is_safe_and_reauth_required(tmp_path: Path) -> None: + secret = "never-return-this-access-token" + + async def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(400, json={"error": {"code": 190, "message": secret}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = FacebookProvider(meta_settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialReauthRequiredError) as raised: + await provider.get_metrics({"access_token": secret}, "page-post-id") + finally: + await client.aclose() + assert secret not in str(raised.value) + + +async def test_meta_analytics_requires_explicit_authorization_and_persists_safe_snapshot(meta_container) -> None: + account = await meta_container.social.accounts.repository.create( + SocialAccount( + workspace_id="workspace-meta", + provider="facebook", + account_type="facebook_page", + external_account_id="page-id", + status="connected", + ) + ) + await meta_container.social.accounts.tokens.store( + "workspace-meta", account.id, {"access_token": "stored-token"}, scopes=["pages_read_engagement"] + ) + readiness = await meta_container.social.analytics.account("workspace-meta", account.id) + assert readiness["status"] == "unavailable" + assert readiness["reason"] == "META_ANALYTICS_ADDITIONAL_AUTHORIZATION_REQUIRED" + assert readiness["required_scopes"] == ["read_insights"] + + await meta_container.social.accounts.tokens.store( + "workspace-meta", + account.id, + {"access_token": "stored-token"}, + scopes=["pages_read_engagement", "read_insights"], + ) + post, targets = await meta_container.social.publishing.posts.create( + SocialPost(workspace_id="workspace-meta", media_asset_id="owned-asset"), + [ + SocialPostTarget( + social_post_id="", + social_account_id=account.id, + provider="facebook", + status="published", + external_post_id="page-post-id", + ) + ], + ) + assert targets + adapter = meta_container.social.accounts.providers.get("facebook") + + async def metrics(_: dict[str, object], __: str) -> dict[str, object]: + return { + "status": "available", + "views": 8, + "impressions": 12, + "likes": 3, + "comments": 1, + "shares": 2, + "raw_metrics": {"access_token": "must-not-leak", "provider_value": 8}, + } + + adapter.get_metrics = metrics # type: ignore[method-assign] + result = await meta_container.social.analytics.post("workspace-meta", post.id) + assert result["metrics"][0]["views"] == 8 + assert "access_token" not in str(result) + assert result["metrics"][0]["raw_metrics"] == {"provider_value": 8} + + +async def test_meta_analytics_consent_is_explicit_and_never_added_to_normal_connection(tmp_path: Path) -> None: + provider = FacebookProvider(meta_settings(tmp_path)) + try: + normal = await provider.get_authorization_url( + state="s" * 32, redirect_uri="https://api.example/callback" + ) + analytics = await provider.get_authorization_url( + state="a" * 32, + redirect_uri="https://api.example/callback", + additional_scopes=provider.capabilities.analytics_required_scopes, + ) + finally: + await provider.close() + assert "read_insights" not in parse_qs(urlparse(normal).query).get("scope", [""])[0] + requested = parse_qs(urlparse(analytics).query)["scope"][0].split() + assert requested == ["pages_read_engagement", "read_insights"] + + +def test_public_social_views_remove_token_like_data() -> None: + secret = "never-expose-me" + account = SocialAccount( + workspace_id="workspace-a", + provider="facebook", + account_type="facebook_page", + external_account_id="page-a", + status="connected", + metadata_json={"access_token": secret, "nested": {"client_secret": secret}, "name": "Page"}, + ) + job = SocialJob( + workspace_id="workspace-a", + social_post_id="post-a", + provider="facebook", + status="queued", + payload_json={"access_token": secret, "media_asset_id": "asset-a"}, + ) + assert secret not in SocialAccountView.from_record(account).model_dump_json() + assert secret not in SocialJobView.from_record(job).model_dump_json() + + +@pytest.mark.skipif( + os.getenv("RUN_META_INTEGRATION_TESTS") != "true", + reason="Set RUN_META_INTEGRATION_TESTS=true with dedicated Meta test credentials.", +) +async def test_live_meta_page_post_insights() -> None: + """Optional live smoke test; OAuth/publishing require separate manual consent setup. + + Required CI-secret variables are deliberately not named or logged by the + application. This test uses a dedicated Page post and never publishes. + """ + + token = os.environ.get("META_TEST_PAGE_ACCESS_TOKEN") + post_id = os.environ.get("META_TEST_PAGE_POST_ID") + if not token or not post_id: + pytest.skip("META_TEST_PAGE_ACCESS_TOKEN and META_TEST_PAGE_POST_ID are not configured.") + settings = Settings(_env_file=None, meta_graph_api_version="v25.0", whisper_model="tiny") + provider = FacebookProvider(settings) + try: + result = await provider.get_metrics({"access_token": token}, post_id) + finally: + await provider.close() + assert result["status"] in {"available", "unavailable"} diff --git a/tests/test_social_foundation.py b/tests/test_social_foundation.py new file mode 100644 index 0000000000000000000000000000000000000000..f129d27bdf8a9e083f8073bfc2e99aad23b064ce --- /dev/null +++ b/tests/test_social_foundation.py @@ -0,0 +1,526 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import uuid4 + +import pytest +from pydantic import ValidationError +from sqlalchemy import select +from starlette.requests import Request + +from app.container import build_container +from app.core.config import Settings +from app.security.policy import ScopePolicy +from app.social.database import SocialDatabase +from app.social.domain.enums import JobStatus +from app.social.domain.errors import ( + SocialAccountNotFoundError, + SocialIdempotencyConflictError, + SocialJobNotFoundError, + SocialMediaInvalidError, + SocialOAuthStateError, + SocialPermissionDeniedError, + SocialPostNotFoundError, + SocialReauthRequiredError, + SocialTransitionError, +) +from app.social.domain.retry import classify_retry +from app.social.domain.state_machine import validate_transition +from app.social.models import OAuthState, SocialAccount, SocialAccountToken, SocialJob, SocialMediaAsset +from app.social.schemas.posts import SocialPostCreate +from app.social.schemas.scheduling import SocialScheduleCreate + + +def social_settings(tmp_path: Path) -> Settings: + return Settings( + _env_file=None, + auth_enabled=False, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}", + social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}", + social_auto_migrate=True, + social_worker_enabled=False, + social_oauth_encryption_key="test-only-encryption-material", + temp_dir=tmp_path / "temp", + output_dir=tmp_path / "outputs", + cleanup_interval_seconds=3600, + whisper_model="tiny", + ) + + +@pytest.fixture +async def social_container(tmp_path: Path): + container = build_container(social_settings(tmp_path)) + await container.social.initialize() + try: + yield container + finally: + await container.social.close() + await container.security_database.close() + + +async def connected_account(container, workspace_id: str, provider: str = "youtube") -> SocialAccount: + return await container.social.accounts.repository.create( + SocialAccount( + workspace_id=workspace_id, + provider=provider, + account_type="channel", + external_account_id=f"external-{workspace_id}-{provider}", + display_name="Test channel", + status="connected", + ) + ) + + +async def registered_output_asset(container, workspace_id: str) -> str: + request_id = str(uuid4()) + output = container.settings.output_dir / request_id + output.mkdir(parents=True, exist_ok=True) + (output / "video.mp4").write_bytes(b"test") + record = await container.social.media_assets.repository.create( + SocialMediaAsset( + workspace_id=workspace_id, + request_id=request_id, + filename="video.mp4", + mime_type="video/mp4", + file_size=4, + ) + ) + return record.id + + +def post_payload(account_id: str, *, title: str = "Example") -> SocialPostCreate: + return SocialPostCreate.model_validate( + { + "media_asset_id": "asset-owned-by-workspace", + "publish_mode": "draft", + "targets": [ + { + "social_account_id": account_id, + "caption": {"title": title, "description": "Description"}, + "youtube": { + "title": title, + "description": "Description", + "privacy_status": "private", + "made_for_kids": False, + }, + } + ], + } + ) + + +async def test_provider_registry_and_capability_matrix(social_container) -> None: + providers = social_container.social.accounts.list_providers() + assert {item.provider.value for item in providers} == { + "youtube", + "facebook", + "instagram", + "tiktok", + "x", + "linkedin", + "telegram", + "whatsapp", + } + assert all(item.available is False for item in providers) + youtube = next(item for item in providers if item.provider.value == "youtube") + assert youtube.capabilities.implementation_status == "implemented" + assert youtube.capabilities.video_upload + assert youtube.capabilities.video_status + assert youtube.capabilities.channel_metadata + assert youtube.configured is False + assert next(item for item in providers if item.provider.value == "telegram").connection_strategy.value == "token_bot" + assert next(item for item in providers if item.provider.value == "whatsapp").connection_strategy.value == "business_api" + + +def test_job_state_machine_and_retry_classification() -> None: + assert validate_transition(JobStatus.DRAFT, JobStatus.SCHEDULED) == JobStatus.SCHEDULED + assert validate_transition(JobStatus.RETRYING, JobStatus.PROCESSING) == JobStatus.PROCESSING + with pytest.raises(SocialTransitionError): + validate_transition(JobStatus.PUBLISHED, JobStatus.QUEUED) + assert classify_retry(status_code=429, attempt=3).retryable + assert classify_retry(status_code=401, attempt=1).refresh_token_first + assert not classify_retry(status_code=403, attempt=1).retryable + assert not classify_retry(status_code=400, attempt=1).retryable + + +def test_only_the_exact_provider_callback_route_is_public() -> None: + policy = ScopePolicy() + + def request_for(path: str) -> Request: + return Request( + {"type": "http", "method": "GET", "path": path, "headers": []} + ) + + assert policy.is_public( + request_for("/v1/social/accounts/youtube/callback") + ) + assert not policy.is_public( + request_for("/v1/social/accounts/youtube/untrusted/callback") + ) + assert not policy.is_public( + Request( + { + "type": "http", + "method": "POST", + "path": "/v1/social/accounts/youtube/callback", + "headers": [], + } + ) + ) + + +async def test_social_auto_migrate_false_does_not_mutate_schema(tmp_path: Path) -> None: + settings = social_settings(tmp_path) + settings.social_auto_migrate = False + database = SocialDatabase(settings) + try: + await database.initialize() + assert not await database.schema_ready() + assert "social_accounts" in await database.missing_tables() + finally: + await database.close() + + +async def test_oauth_state_is_random_expiring_single_use_and_tenant_bound( + social_container, +) -> None: + state = await social_container.social.oauth.states.create( + provider="youtube", + workspace_id="workspace-a", + user_id="user-a", + redirect_uri="https://api.example/v1/social/accounts/youtube/callback", + ) + assert len(state.state) >= 32 + consumed = await social_container.social.oauth.states.consume( + state=state.state, provider="youtube" + ) + assert consumed.workspace_id == "workspace-a" + with pytest.raises(SocialOAuthStateError): + await social_container.social.oauth.states.consume( + state=state.state, provider="youtube" + ) + + expired = OAuthState( + state="expired-state", + provider="youtube", + workspace_id="workspace-a", + user_id="user-a", + redirect_uri="https://api.example/callback", + expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + async with social_container.social.database.session() as session: + session.add(expired) + await session.commit() + with pytest.raises(SocialOAuthStateError): + await social_container.social.oauth.states.consume( + state="expired-state", provider="youtube" + ) + + wrong_provider = await social_container.social.oauth.states.create( + provider="youtube", + workspace_id="workspace-a", + user_id="user-a", + redirect_uri="https://api.example/v1/social/accounts/youtube/callback", + ) + with pytest.raises(SocialOAuthStateError): + await social_container.social.oauth.states.consume( + state=wrong_provider.state, provider="linkedin" + ) + assert ( + await social_container.social.oauth.states.consume( + state=wrong_provider.state, provider="youtube" + ) + ).workspace_id == "workspace-a" + with pytest.raises(SocialOAuthStateError): + await social_container.social.oauth.states.consume( + state="not-a-valid-state", provider="youtube" + ) + + +async def test_oauth_redirect_uri_is_backend_owned(social_container) -> None: + oauth = social_container.social.oauth + with pytest.raises(SocialPermissionDeniedError): + oauth._redirect_uri( + "youtube", "https://attacker.example/v1/social/accounts/youtube/callback" + ) + social_container.settings.social_oauth_redirect_base_url = "https://api.example" + expected = "https://api.example/v1/social/accounts/youtube/callback" + assert oauth._redirect_uri("youtube", None) == expected + with pytest.raises(SocialPermissionDeniedError): + oauth._redirect_uri( + "youtube", "https://attacker.example/v1/social/accounts/youtube/callback" + ) + + +async def test_token_service_encrypts_and_never_returns_storage_metadata( + social_container, +) -> None: + account = await connected_account(social_container, "workspace-token") + secret = "provider-access-token-that-must-not-leak" + await social_container.social.accounts.tokens.store( + "workspace-token", + account.id, + {"access_token": secret, "refresh_token": "refresh-secret"}, + scopes=["upload"], + ) + async with social_container.social.database.session() as session: + row = await session.scalar( + select(SocialAccountToken).where( + SocialAccountToken.social_account_id == account.id + ) + ) + assert row is not None + assert secret not in (row.encrypted_payload or "") + assert await social_container.social.accounts.tokens.retrieve( + "workspace-token", account.id + ) == { + "access_token": secret, + "refresh_token": "refresh-secret", + } + view = await social_container.social.accounts.get("workspace-token", account.id) + assert "token" not in view.model_dump_json().lower() + with pytest.raises(SocialReauthRequiredError): + await social_container.social.accounts.tokens.retrieve("workspace-other", account.id) + + +async def test_workspace_ownership_idempotency_and_multi_target_foundation( + social_container, +) -> None: + account = await connected_account(social_container, "workspace-a") + payload = post_payload(account.id) + first = await social_container.social.publishing.create( + workspace_id="workspace-a", + user_id="user-a", + payload=payload, + idempotency_key="create-post-key", + ) + replay = await social_container.social.publishing.create( + workspace_id="workspace-a", + user_id="user-a", + payload=payload, + idempotency_key="create-post-key", + ) + assert replay.id == first.id + assert len(first.targets) == 1 + + with pytest.raises(SocialIdempotencyConflictError): + await social_container.social.publishing.create( + workspace_id="workspace-a", + user_id="user-a", + payload=post_payload(account.id, title="Different"), + idempotency_key="create-post-key", + ) + with pytest.raises(SocialAccountNotFoundError): + await social_container.social.publishing.create( + workspace_id="workspace-b", + user_id="user-b", + payload=payload, + idempotency_key="cross-workspace-key", + ) + + +async def test_cross_workspace_asset_cannot_be_queued_for_publishing(social_container) -> None: + asset_id = await registered_output_asset(social_container, "workspace-a") + account_b = await connected_account(social_container, "workspace-b") + post = await social_container.social.publishing.create( + workspace_id="workspace-b", + user_id="user-b", + payload=SocialPostCreate.model_validate( + { + **post_payload(account_b.id).model_dump(mode="json"), + "media_asset_id": asset_id, + } + ), + idempotency_key="cross-workspace-asset-post", + ) + with pytest.raises(SocialMediaInvalidError): + await social_container.social.publishing.queue( + "workspace-b", post.id, idempotency_key="cross-workspace-asset-publish" + ) + + +async def test_cross_workspace_records_cannot_be_read_or_modified(social_container) -> None: + account_a = await connected_account(social_container, "workspace-a") + account_b = await connected_account(social_container, "workspace-b") + post_b = await social_container.social.publishing.create( + workspace_id="workspace-b", + user_id="user-b", + payload=post_payload(account_b.id), + idempotency_key="workspace-b-post", + ) + job_b = ( + await social_container.social.jobs.repository.create_many( + [ + SocialJob( + workspace_id="workspace-b", + social_post_id=post_b.id, + social_post_target_id=post_b.targets[0].id, + provider="youtube", + status="queued", + idempotency_key="workspace-b-job", + ) + ] + ) + )[0] + await social_container.social.accounts.tokens.store( + "workspace-b", account_b.id, {"access_token": "workspace-b-secret"} + ) + + with pytest.raises(SocialAccountNotFoundError): + await social_container.social.accounts.get("workspace-a", account_b.id) + with pytest.raises(SocialAccountNotFoundError): + await social_container.social.accounts.repository.set_status( + "workspace-a", account_b.id, "disconnected" + ) + with pytest.raises(SocialPostNotFoundError): + await social_container.social.publishing.get("workspace-a", post_b.id) + with pytest.raises(SocialPostNotFoundError): + await social_container.social.publishing.posts.set_status( + "workspace-a", post_b.id, "cancelled" + ) + with pytest.raises(SocialJobNotFoundError): + await social_container.social.jobs.get("workspace-a", job_b.id) + with pytest.raises(SocialJobNotFoundError): + await social_container.social.jobs.repository.transition( + "workspace-a", job_b.id, "cancelled" + ) + with pytest.raises(SocialReauthRequiredError): + await social_container.social.accounts.tokens.retrieve("workspace-a", account_b.id) + with pytest.raises(SocialAccountNotFoundError): + await social_container.social.analytics.account("workspace-a", account_b.id) + with pytest.raises(SocialPostNotFoundError): + await social_container.social.analytics.post("workspace-a", post_b.id) + assert account_a.id != account_b.id + + +async def test_token_401_is_refreshed_and_retried_once(social_container, monkeypatch) -> None: + account = await connected_account(social_container, "workspace-refresh") + await social_container.social.accounts.tokens.store( + "workspace-refresh", + account.id, + {"access_token": "expired-access", "refresh_token": "refresh-token"}, + ) + adapter = social_container.social.accounts.providers.get("youtube") + + async def refreshed(_: dict[str, object]) -> dict[str, object]: + return {"access_token": "fresh-access", "expires_in": 3600} + + monkeypatch.setattr(adapter, "refresh_token", refreshed) + received: list[str] = [] + + async def protected_call(token: dict[str, object]) -> str: + received.append(str(token["access_token"])) + if len(received) == 1: + raise SocialReauthRequiredError("first credential was rejected") + return "ok" + + assert await social_container.social.oauth.execute_with_reauth_retry( + workspace_id="workspace-refresh", + account_id=account.id, + operation=protected_call, + ) == "ok" + assert received == ["expired-access", "fresh-access"] + + +async def test_resumable_upload_state_is_encrypted_and_excluded_from_job_views( + social_container, +) -> None: + account = await connected_account(social_container, "workspace-upload-state") + post = await social_container.social.publishing.create( + workspace_id="workspace-upload-state", + user_id="user", + payload=post_payload(account.id), + idempotency_key="upload-state-post", + ) + job = ( + await social_container.social.jobs.repository.create_many( + [ + SocialJob( + workspace_id="workspace-upload-state", + social_post_id=post.id, + social_post_target_id=post.targets[0].id, + provider="youtube", + status="queued", + idempotency_key="upload-state-job", + ) + ] + ) + )[0] + session_url = "https://www.googleapis.com/upload/youtube/v3/videos?upload_id=bearer-like" + await social_container.social.jobs.repository.set_provider_state( + "workspace-upload-state", job.id, {"youtube_upload_session_url": session_url} + ) + async with social_container.social.database.session("workspace-upload-state") as session: + stored = await session.scalar(select(SocialJob).where(SocialJob.id == job.id)) + assert stored is not None and session_url not in (stored.provider_state_encrypted or "") + assert await social_container.social.jobs.repository.get_provider_state( + "workspace-upload-state", job.id + ) == {"youtube_upload_session_url": session_url} + view = await social_container.social.jobs.get("workspace-upload-state", job.id) + assert session_url not in view.model_dump_json() + + +async def test_scheduling_normalizes_to_utc_and_preserves_iana_timezone( + social_container, +) -> None: + account = await connected_account(social_container, "workspace-schedule") + asset_id = await registered_output_asset(social_container, "workspace-schedule") + post = await social_container.social.publishing.create( + workspace_id="workspace-schedule", + user_id="user", + payload=SocialPostCreate.model_validate( + { + **post_payload(account.id).model_dump(mode="json"), + "media_asset_id": asset_id, + } + ), + idempotency_key="schedule-create-key", + ) + payload = SocialScheduleCreate.model_validate( + { + "scheduled_at": "2030-02-01T14:00:00+01:00", + "timezone": "Africa/Lagos", + } + ) + schedule = await social_container.social.scheduling.schedule( + "workspace-schedule", post.id, payload + ) + assert schedule.timezone == "Africa/Lagos" + assert schedule.scheduled_at.astimezone(timezone.utc).hour == 13 + replacement = await social_container.social.scheduling.schedule( + "workspace-schedule", + post.id, + SocialScheduleCreate.model_validate( + { + "scheduled_at": "2030-02-01T15:00:00+01:00", + "timezone": "Africa/Lagos", + } + ), + ) + assert replacement.id == schedule.id + assert replacement.scheduled_at.astimezone(timezone.utc).hour == 14 + + +def test_schedule_rejects_past_naive_and_invalid_timezone_values() -> None: + with pytest.raises(ValidationError): + SocialScheduleCreate.model_validate( + {"scheduled_at": "2000-01-01T00:00:00+00:00", "timezone": "UTC"} + ) + with pytest.raises(ValidationError): + SocialScheduleCreate.model_validate( + {"scheduled_at": "2030-03-10T01:30:00", "timezone": "America/New_York"} + ) + with pytest.raises(ValidationError): + SocialScheduleCreate.model_validate( + { + "scheduled_at": "2030-03-10T01:30:00-05:00", + "timezone": "Not/A_Timezone", + } + ) + assert SocialScheduleCreate.model_validate( + { + "scheduled_at": "2030-03-10T01:30:00-05:00", + "timezone": "America/New_York", + } + ).timezone == "America/New_York" diff --git a/tests/test_tiktok_foundation.py b/tests/test_tiktok_foundation.py new file mode 100644 index 0000000000000000000000000000000000000000..3a0b5b5770574848952de2c17db01b2ebbd0affb --- /dev/null +++ b/tests/test_tiktok_foundation.py @@ -0,0 +1,371 @@ +"""Phase 4A TikTok Login Kit foundation coverage. + +All provider traffic uses MockTransport. Normal CI never needs TikTok +credentials or an interactive browser consent flow. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from sqlalchemy import select + +from app.container import build_container +from app.core.config import Settings +from app.social.domain.errors import ( + SocialAccountNotFoundError, + SocialOAuthStateError, + SocialPermissionDeniedError, + SocialReauthRequiredError, +) +from app.social.models import OAuthState, SocialAccountToken +from app.social.providers.tiktok import TikTokProvider +from app.social.schemas.accounts import SocialAccountConnectRequest + + +def tiktok_settings(tmp_path: Path) -> Settings: + return Settings( + _env_file=None, + auth_enabled=False, + database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}", + social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}", + social_auto_migrate=True, + social_worker_enabled=False, + social_oauth_encryption_key="test-only-encryption-material", + social_oauth_redirect_base_url="https://api.example.com", + tiktok_client_key="tiktok-client-key", + tiktok_client_secret="tiktok-client-secret", + tiktok_redirect_uri=( + "https://api.example.com/v1/social/accounts/tiktok/callback" + ), + temp_dir=tmp_path / "temp", + output_dir=tmp_path / "outputs", + cleanup_interval_seconds=3600, + whisper_model="tiny", + ) + + +async def test_tiktok_web_authorization_uses_minimum_scope_and_no_unsupported_pkce( + tmp_path: Path, +) -> None: + provider = TikTokProvider(tiktok_settings(tmp_path)) + try: + url = await provider.get_authorization_url( + state="s" * 43, + redirect_uri="https://api.example.com/v1/social/accounts/tiktok/callback", + code_challenge="challenge-that-web-login-kit-does-not-support", + ) + finally: + await provider.close() + + parsed = urlparse(url) + query = parse_qs(parsed.query) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == ( + "https://www.tiktok.com/v2/auth/authorize/" + ) + assert query["client_key"] == ["tiktok-client-key"] + assert query["response_type"] == ["code"] + assert query["scope"] == ["user.info.basic"] + assert query["state"] == ["s" * 43] + assert "code_challenge" not in query + assert "code_challenge_method" not in query + + +async def test_tiktok_exchange_refresh_discovery_and_revoke_use_official_v2_endpoints( + tmp_path: Path, +) -> None: + calls: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + if request.url.path == "/v2/oauth/token/": + form = parse_qs(request.content.decode()) + assert form["client_key"] == ["tiktok-client-key"] + assert form["client_secret"] == ["tiktok-client-secret"] + if form["grant_type"] == ["authorization_code"]: + assert form["code"] == ["authorization-code"] + assert form["redirect_uri"] == [ + "https://api.example.com/v1/social/accounts/tiktok/callback" + ] + assert "code_verifier" not in form + else: + assert form["grant_type"] == ["refresh_token"] + assert form["refresh_token"] == ["refresh-token"] + return httpx.Response( + 200, + json={ + "access_token": "access-token", + "refresh_token": "rotated-refresh-token", + "expires_in": 86400, + "refresh_expires_in": 31536000, + "open_id": "open-id", + "scope": "user.info.basic", + "token_type": "Bearer", + }, + ) + if request.url.path == "/v2/user/info/": + assert request.headers["authorization"] == "Bearer access-token" + assert parse_qs(request.url.query.decode())["fields"] == [ + "open_id,union_id,avatar_url,display_name" + ] + return httpx.Response( + 200, + json={ + "data": { + "user": { + "open_id": "open-id", + "union_id": "union-id", + "display_name": "TikTok Creator", + "avatar_url": "https://example.com/avatar.jpg", + } + }, + "error": {"code": "ok", "message": ""}, + }, + ) + assert request.url.path == "/v2/oauth/revoke/" + form = parse_qs(request.content.decode()) + assert form == { + "client_key": ["tiktok-client-key"], + "client_secret": ["tiktok-client-secret"], + "token": ["access-token"], + } + return httpx.Response(200) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(tiktok_settings(tmp_path), http_client=client) + try: + token = await provider.exchange_code( + code="authorization-code", + redirect_uri="https://api.example.com/v1/social/accounts/tiktok/callback", + code_verifier="unused-web-verifier", + ) + account = await provider.get_account(token) + refreshed = await provider.refresh_token( + {"access_token": "old-access", "refresh_token": "refresh-token"} + ) + await provider.revoke_token({"access_token": "access-token"}) + finally: + await client.aclose() + + assert account == { + "external_account_id": "open-id", + "account_type": "creator", + "username": None, + "display_name": "TikTok Creator", + "avatar_url": "https://example.com/avatar.jpg", + "metadata": { + "tiktok_open_id": "open-id", + "tiktok_union_id": "union-id", + }, + } + assert refreshed["refresh_token"] == "rotated-refresh-token" + assert calls == [ + "/v2/oauth/token/", + "/v2/user/info/", + "/v2/oauth/token/", + "/v2/oauth/revoke/", + ] + + +async def test_tiktok_invalid_code_is_normalized_without_provider_secret( + tmp_path: Path, +) -> None: + secret = "authorization-code-that-must-not-leak" + + async def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={ + "error": "invalid_grant", + "error_description": f"bad code {secret}", + "log_id": "provider-log-id", + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(tiktok_settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialReauthRequiredError) as raised: + await provider.exchange_code( + code=secret, + redirect_uri="https://api.example.com/v1/social/accounts/tiktok/callback", + ) + finally: + await client.aclose() + assert secret not in str(raised.value) + assert "provider-log-id" not in str(raised.value) + + +async def test_tiktok_oauth_callback_is_single_use_duplicate_safe_and_workspace_bound( + tmp_path: Path, +) -> None: + settings = tiktok_settings(tmp_path) + container = build_container(settings) + await container.social.initialize() + adapter = container.social.accounts.providers.get("tiktok") + assert isinstance(adapter, TikTokProvider) + await adapter._client.aclose() + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v2/oauth/token/": + return httpx.Response( + 200, + json={ + "access_token": "token-that-must-stay-encrypted", + "refresh_token": "refresh-that-must-stay-encrypted", + "expires_in": 86400, + "scope": "user.info.basic", + "token_type": "Bearer", + }, + ) + return httpx.Response( + 200, + json={ + "data": { + "user": { + "open_id": "stable-open-id", + "display_name": "Workspace Creator", + } + }, + "error": {"code": "ok", "message": ""}, + }, + ) + + adapter._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + adapter._owns_client = True + try: + first_connect = await container.social.oauth.connect( + provider="tiktok", + workspace_id="workspace-a", + user_id="user-a", + payload=SocialAccountConnectRequest(), + ) + first_state = parse_qs(urlparse(first_connect.authorization_url or "").query)[ + "state" + ][0] + assert "code_challenge" not in parse_qs( + urlparse(first_connect.authorization_url or "").query + ) + first = await container.social.oauth.callback( + provider="tiktok", state=first_state, code="first-code" + ) + with pytest.raises(SocialOAuthStateError): + await container.social.oauth.callback( + provider="tiktok", state=first_state, code="replayed-code" + ) + + second_connect = await container.social.oauth.connect( + provider="tiktok", + workspace_id="workspace-a", + user_id="user-a", + payload=SocialAccountConnectRequest(), + ) + second_state = parse_qs( + urlparse(second_connect.authorization_url or "").query + )["state"][0] + second = await container.social.oauth.callback( + provider="tiktok", state=second_state, code="second-code" + ) + + assert first.id == second.id + accounts = await container.social.accounts.list("workspace-a") + assert [account.id for account in accounts] == [first.id] + assert "token-that-must-stay-encrypted" not in first.model_dump_json() + with pytest.raises(SocialAccountNotFoundError): + await container.social.accounts.get("workspace-b", first.id) + + async with container.social.database.session("workspace-a") as session: + stored = await session.scalar( + select(SocialAccountToken).where( + SocialAccountToken.social_account_id == first.id + ) + ) + assert stored is not None + assert stored.encrypted_payload + assert "token-that-must-stay-encrypted" not in stored.encrypted_payload + finally: + await container.social.close() + await container.security_database.close() + + +async def test_tiktok_state_expiry_provider_binding_and_redirect_validation( + tmp_path: Path, +) -> None: + container = build_container(tiktok_settings(tmp_path)) + await container.social.initialize() + try: + assert container.social.oauth._redirect_uri("tiktok", None) == ( + "https://api.example.com/v1/social/accounts/tiktok/callback" + ) + with pytest.raises(SocialPermissionDeniedError): + container.social.oauth._redirect_uri( + "tiktok", + "https://attacker.example/v1/social/accounts/tiktok/callback", + ) + + state = await container.social.oauth.states.create( + provider="tiktok", + workspace_id="workspace-a", + user_id="user-a", + redirect_uri=settings_redirect(container.settings), + ) + with pytest.raises(SocialOAuthStateError): + await container.social.oauth.states.consume( + state=state.state, provider="youtube" + ) + consumed = await container.social.oauth.states.consume( + state=state.state, provider="tiktok" + ) + assert consumed.workspace_id == "workspace-a" + assert consumed.user_id == "user-a" + + expired = OAuthState( + state="expired-tiktok-state-value-that-is-long-enough", + provider="tiktok", + workspace_id="workspace-a", + user_id="user-a", + redirect_uri=settings_redirect(container.settings), + expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + async with container.social.database.session("workspace-a") as session: + session.add(expired) + await session.commit() + with pytest.raises(SocialOAuthStateError): + await container.social.oauth.states.consume( + state=expired.state, provider="tiktok" + ) + finally: + await container.social.close() + await container.security_database.close() + + +def settings_redirect(settings: Settings) -> str: + return settings.tiktok_redirect_uri + + +async def test_tiktok_capability_discovery_does_not_advertise_publishing( + tmp_path: Path, +) -> None: + container = build_container(tiktok_settings(tmp_path)) + try: + tiktok = container.social.accounts.get_provider("tiktok") + assert tiktok.available + assert tiktok.configured + assert tiktok.capabilities.implementation_status == "implemented" + assert tiktok.capabilities.required_scopes == ["user.info.basic"] + assert tiktok.capabilities.account_types == ["creator"] + assert not tiktok.capabilities.video + assert not tiktok.capabilities.video_upload + assert not tiktok.capabilities.direct_publish + assert not tiktok.capabilities.draft_upload + assert not tiktok.capabilities.scheduled_publish + assert not tiktok.capabilities.delete_post + assert tiktok.capabilities.analytics + assert tiktok.capabilities.analytics_required_scopes == ["video.list"] + finally: + await container.social.close() + await container.security_database.close() diff --git a/tests/test_tiktok_production.py b/tests/test_tiktok_production.py new file mode 100644 index 0000000000000000000000000000000000000000..ace7e7f53aa51064833265387e9044ef8ac3ae4e --- /dev/null +++ b/tests/test_tiktok_production.py @@ -0,0 +1,598 @@ +"""Phase 4C TikTok analytics, security, tenancy, and certification coverage. + +Normal CI uses only SQLite and mocked official TikTok endpoints. Live provider +traffic is opt-in and requires a dedicated test creator plus explicit consent +to create a SELF_ONLY post. +""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from sqlalchemy import select + +from app.container import build_container +from app.core.config import Settings +from app.core.logger import JsonFormatter +from app.mcp.registry import MCPRegistry +from app.mcp.server import create_mcp_server +from app.security.context import AuthContext, auth_context, http_auth_applied +from app.services.ffprobe_service import FFprobeService +from app.social.domain.errors import ( + SocialAccountNotFoundError, + SocialJobNotFoundError, + SocialMediaInvalidError, + SocialPermissionDeniedError, + SocialPostNotFoundError, + SocialProviderUnavailableError, + SocialPublishFailedError, + SocialRateLimitedError, + SocialReauthRequiredError, +) +from app.social.domain.retry import classify_retry +from app.social.models import ( + SocialAccount, + SocialAuditEvent, + SocialJob, + SocialMediaAsset, + SocialPost, + SocialPostMetric, + SocialPostTarget, +) +from app.social.providers.tiktok import TikTokProvider +from app.social.schemas.accounts import SocialAccountConnectRequest, SocialAccountView +from app.social.schemas.jobs import SocialJobView +from app.social.schemas.tiktok import TikTokPostMetadata + + +def phase4c_settings(tmp_path: Path, **overrides: object) -> Settings: + values: dict[str, object] = { + "_env_file": None, + "auth_enabled": False, + "database_url": f"sqlite+aiosqlite:///{tmp_path / 'security.db'}", + "social_database_url": f"sqlite+aiosqlite:///{tmp_path / 'social.db'}", + "social_auto_migrate": True, + "social_worker_enabled": False, + "social_oauth_encryption_key": "phase-4c-test-encryption-material", + "tiktok_client_key": "tiktok-client-key", + "tiktok_client_secret": "tiktok-client-secret", + "tiktok_redirect_uri": ( + "https://api.example.com/v1/social/accounts/tiktok/callback" + ), + "tiktok_direct_post_enabled": True, + "temp_dir": tmp_path / "temp", + "output_dir": tmp_path / "outputs", + "cleanup_interval_seconds": 3600, + "whisper_model": "tiny", + } + values.update(overrides) + return Settings(**values) + + +@pytest.fixture +async def phase4c_container(tmp_path: Path): + container = build_container(phase4c_settings(tmp_path)) + await container.social.initialize() + try: + yield container + finally: + await container.social.close() + await container.security_database.close() + + +async def test_tiktok_video_query_analytics_normalizes_only_official_metrics( + tmp_path: Path, +) -> None: + secret = "analytics-access-token-that-must-not-leak" + + async def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v2/video/query/" + assert request.headers["authorization"] == f"Bearer {secret}" + assert secret not in str(request.url) + fields = parse_qs(request.url.query.decode())["fields"][0].split(",") + assert {"view_count", "like_count", "comment_count", "share_count"} <= set( + fields + ) + assert json.loads(request.content) == { + "filters": {"video_ids": ["public-video-id"]} + } + return httpx.Response( + 200, + json={ + "data": { + "videos": [ + { + "id": "public-video-id", + "create_time": 1_785_456_000, + "share_url": "https://www.tiktok.com/@creator/video/public-video-id", + "view_count": 101, + "like_count": 22, + "comment_count": 3, + "share_count": 4, + "title": "Provider-returned title", + "access_token": secret, + } + ] + }, + "error": {"code": "ok", "message": "", "log_id": "safe-log-id"}, + }, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(phase4c_settings(tmp_path), http_client=client) + try: + result = await provider.get_metrics( + {"access_token": secret}, "public-video-id" + ) + finally: + await client.aclose() + + assert result["status"] == "available" + assert result["views"] == 101 + assert result["likes"] == 22 + assert result["comments"] == 3 + assert result["shares"] == 4 + assert result["published_at"] == 1_785_456_000 + assert result["raw_metrics"]["title"] == "Provider-returned title" + assert secret not in json.dumps(result) + + +async def test_tiktok_analytics_scope_is_explicit_and_persists_public_video_metrics( + phase4c_container, +) -> None: + social = phase4c_container.social + provider = social.accounts.providers.get("tiktok") + assert provider.capabilities.analytics + assert provider.capabilities.analytics_required_scopes == ["video.list"] + + normal = await social.oauth.connect( + provider="tiktok", + workspace_id="workspace-tiktok", + user_id="user-tiktok", + payload=SocialAccountConnectRequest(), + ) + elevated = await social.oauth.connect( + provider="tiktok", + workspace_id="workspace-tiktok", + user_id="user-tiktok", + payload=SocialAccountConnectRequest(authorization_purpose="analytics"), + ) + assert "video.list" not in parse_qs( + urlparse(normal.authorization_url or "").query + )["scope"][0].split(",") + assert "video.list" in parse_qs( + urlparse(elevated.authorization_url or "").query + )["scope"][0].split(",") + + account = await social.accounts.repository.create( + SocialAccount( + workspace_id="workspace-tiktok", + provider="tiktok", + account_type="creator", + external_account_id="open-id", + status="connected", + ) + ) + await social.accounts.tokens.store( + "workspace-tiktok", + account.id, + {"access_token": "encrypted-analytics-token"}, + scopes=["user.info.basic", "video.publish"], + ) + readiness = await social.analytics.account("workspace-tiktok", account.id) + assert readiness == { + "account_id": account.id, + "metrics": [], + "status": "unavailable", + "reason": "TIKTOK_ANALYTICS_ADDITIONAL_AUTHORIZATION_REQUIRED", + "required_scopes": ["video.list"], + } + + await social.accounts.tokens.store( + "workspace-tiktok", + account.id, + {"access_token": "encrypted-analytics-token"}, + scopes=["user.info.basic", "video.publish", "video.list"], + ) + post, targets = await social.publishing.posts.create( + SocialPost( + workspace_id="workspace-tiktok", + media_asset_id="workspace-owned-asset", + ), + [ + SocialPostTarget( + social_post_id="", + social_account_id=account.id, + provider="tiktok", + status="published", + external_post_id="private-publish-id", + platform_metadata={ + "provider": {"public_post_ids": ["public-video-id"]} + }, + ) + ], + ) + requested_ids: list[str] = [] + + async def metrics(_: dict[str, object], video_id: str) -> dict[str, object]: + requested_ids.append(video_id) + return { + "status": "available", + "views": 12, + "likes": 3, + "comments": 2, + "shares": 1, + "raw_metrics": { + "view_count": 12, + "authorization": "Bearer secret-that-must-not-persist", + }, + } + + provider.get_metrics = metrics # type: ignore[method-assign] + result = await social.analytics.post("workspace-tiktok", post.id) + assert requested_ids == ["public-video-id"] + assert result["metrics"][0]["views"] == 12 + assert result["metrics"][0]["raw_metrics"] == {"view_count": 12} + assert "secret-that-must-not-persist" not in str(result) + + async with social.database.session("workspace-tiktok") as session: + record = await session.scalar( + select(SocialPostMetric).where( + SocialPostMetric.social_post_target_id == targets[0].id + ) + ) + assert record is not None + assert record.raw_metrics == {"view_count": 12} + + +async def test_tiktok_cross_workspace_accounts_targets_jobs_assets_and_analytics_fail( + phase4c_container, +) -> None: + social = phase4c_container.social + account_b = await social.accounts.repository.create( + SocialAccount( + workspace_id="workspace-b", + provider="tiktok", + account_type="creator", + external_account_id="workspace-b-open-id", + status="connected", + ) + ) + await social.accounts.tokens.store( + "workspace-b", + account_b.id, + {"access_token": "workspace-b-token"}, + scopes=["user.info.basic", "video.list"], + ) + asset_b = await social.media_assets.repository.create( + SocialMediaAsset( + workspace_id="workspace-b", + request_id="00000000-0000-0000-0000-00000000000b", + filename="video.mp4", + mime_type="video/mp4", + file_size=10, + ) + ) + post_b, targets_b = await social.publishing.posts.create( + SocialPost(workspace_id="workspace-b", media_asset_id=asset_b.id), + [ + SocialPostTarget( + social_post_id="", + social_account_id=account_b.id, + provider="tiktok", + status="published", + external_post_id="workspace-b-publish-id", + platform_metadata={ + "provider": {"public_post_ids": ["workspace-b-video-id"]} + }, + ) + ], + ) + job_b = ( + await social.jobs.repository.create_many( + [ + SocialJob( + workspace_id="workspace-b", + social_post_id=post_b.id, + social_post_target_id=targets_b[0].id, + provider="tiktok", + status="queued", + idempotency_key="workspace-b-job-key", + ) + ] + ) + )[0] + + with pytest.raises(SocialAccountNotFoundError): + await social.accounts.get("workspace-a", account_b.id) + with pytest.raises(SocialPostNotFoundError): + await social.publishing.get("workspace-a", post_b.id) + with pytest.raises(SocialPostNotFoundError): + await social.publishing.posts.set_target_status( + "workspace-a", targets_b[0].id, "failed" + ) + with pytest.raises(SocialJobNotFoundError): + await social.jobs.get("workspace-a", job_b.id) + with pytest.raises(SocialMediaInvalidError): + await social.media_assets.repository.get("workspace-a", asset_b.id) + with pytest.raises(SocialAccountNotFoundError): + await social.analytics.account("workspace-a", account_b.id) + with pytest.raises(SocialPostNotFoundError): + await social.analytics.post("workspace-a", post_b.id) + + +async def test_tiktok_tokens_are_redacted_from_views_logs_and_audit_records( + phase4c_container, +) -> None: + secret = "phase-4c-secret-token" + account = SocialAccount( + workspace_id="workspace-a", + provider="tiktok", + account_type="creator", + external_account_id="open-id", + status="connected", + metadata_json={ + "display": "Creator", + "access_token": secret, + "provider_message": f"Authorization: Bearer {secret}", + }, + ) + job = SocialJob( + workspace_id="workspace-a", + social_post_id="post-a", + provider="tiktok", + status="queued", + payload_json={"access_token": secret, "message": f"Bearer {secret}"}, + provider_state_encrypted=f"encrypted:{secret}", + ) + assert secret not in SocialAccountView.from_record(account).model_dump_json() + assert secret not in SocialJobView.from_record(job).model_dump_json() + + record = logging.LogRecord( + "security-test", + logging.ERROR, + __file__, + 1, + f"provider failed Authorization: Bearer {secret}", + (), + None, + ) + record.provider_payload = { + "refresh_token": secret, + "message": f"access_token={secret}", + } + rendered = JsonFormatter().format(record) + assert secret not in rendered + assert "[REDACTED]" in rendered + + await phase4c_container.social.audit.record( + workspace_id="workspace-a", + event_type="SOCIAL_TIKTOK_SECURITY_TEST", + provider="tiktok", + metadata={ + "client_secret": secret, + "message": f"Authorization: Bearer {secret}", + }, + ) + async with phase4c_container.social.database.session("workspace-a") as session: + audit = await session.scalar( + select(SocialAuditEvent).where( + SocialAuditEvent.event_type == "SOCIAL_TIKTOK_SECURITY_TEST" + ) + ) + assert audit is not None + assert secret not in json.dumps(audit.metadata_json) + + +@pytest.mark.parametrize( + ("status_code", "code", "error_type", "retryable"), + [ + (429, "rate_limit_exceeded", SocialRateLimitedError, True), + (500, "internal_error", SocialProviderUnavailableError, True), + (502, "server_error", SocialProviderUnavailableError, True), + (503, "server_error", SocialProviderUnavailableError, True), + (504, "server_error", SocialProviderUnavailableError, True), + (401, "access_token_expired", SocialReauthRequiredError, True), + (403, "scope_not_authorized", SocialPermissionDeniedError, False), + (400, "invalid_param", SocialPublishFailedError, False), + ], +) +def test_tiktok_retry_matrix_is_bounded_and_permanent_errors_fail( + status_code: int, + code: str, + error_type: type[Exception], + retryable: bool, +) -> None: + response = httpx.Response(status_code, json={"error": {"code": code}}) + with pytest.raises(error_type) as raised: + TikTokProvider._raise_tiktok_error( + response, response.json(), operation="production audit" + ) + decision = classify_retry( + status_code=getattr(raised.value, "status_code", status_code), attempt=1 + ) + assert decision.retryable is retryable + if status_code == 401: + assert decision.refresh_token_first + assert not classify_retry(status_code=401, attempt=2).retryable + + +async def test_tiktok_network_timeout_is_safe_and_retryable(tmp_path: Path) -> None: + secret = "timeout-token-that-must-not-leak" + + async def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("Authorization: Bearer " + secret, request=request) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(phase4c_settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialProviderUnavailableError) as raised: + await provider.get_metrics({"access_token": secret}, "video-id") + finally: + await client.aclose() + assert secret not in str(raised.value) + assert classify_retry(status_code=raised.value.status_code, attempt=1).retryable + + +async def test_mcp_registers_social_contract_and_enforces_analytics_scope( + phase4c_container, +) -> None: + server = create_mcp_server(phase4c_container) + tools = {tool.name for tool in await server.list_tools()} + assert { + "social.list_providers", + "social.get_capabilities", + "social.list_accounts", + "social.create_post", + "social.publish_post", + "social.schedule_post", + "social.get_job", + "social.get_analytics", + } <= tools + + context = AuthContext( + api_key_id="workspace-a", + key_name="phase-4c", + key_prefix="mp_test", + environment="test", + role="viewer", + scopes=frozenset({"social:accounts:read"}), + requests_per_minute=100, + concurrent_jobs=2, + uploads_per_hour=10, + processing_bytes_per_day=1_000_000, + expires_at=None, + ) + auth_token = auth_context.set(context) + http_token = http_auth_applied.set(True) + called = False + + async def forbidden_action() -> dict[str, object]: + nonlocal called + called = True + return {"metrics": []} + + try: + result = await MCPRegistry(phase4c_container).run_metadata_tool( + "social.get_analytics", + forbidden_action, + required_scope="social:analytics:read", + ) + finally: + http_auth_applied.reset(http_token) + auth_context.reset(auth_token) + assert result["success"] is False + assert result["error"]["code"] == "FORBIDDEN" + assert not called + assert "token" not in json.dumps(result).lower() + + +@pytest.mark.skipif( + os.getenv("RUN_TIKTOK_INTEGRATION_TESTS", "").lower() != "true", + reason="Set RUN_TIKTOK_INTEGRATION_TESTS=true for a dedicated TikTok test creator.", +) +async def test_live_tiktok_self_only_publish_status_and_analytics() -> None: + """Optional destructive live smoke test guarded by two explicit opt-ins. + + Required secrets are read only from the test process environment. TikTok + currently provides no official delete endpoint, so the test insists on + SELF_ONLY privacy and documents that the created post remains in the + dedicated test account. + """ + + if os.getenv("TIKTOK_TEST_ALLOW_PUBLISH", "").lower() != "true": + pytest.skip("Set TIKTOK_TEST_ALLOW_PUBLISH=true to create a SELF_ONLY post.") + token = os.getenv("TIKTOK_TEST_ACCESS_TOKEN") + media_value = os.getenv("TIKTOK_TEST_VIDEO_PATH") + client_key = os.getenv("TIKTOK_CLIENT_KEY") + client_secret = os.getenv("TIKTOK_CLIENT_SECRET") + redirect_uri = os.getenv("TIKTOK_REDIRECT_URI") + if not all((token, media_value, client_key, client_secret, redirect_uri)): + pytest.skip("Dedicated TikTok credentials, token, and test video are not configured.") + media_path = Path(str(media_value)).resolve() + if not media_path.is_file(): + pytest.skip("TIKTOK_TEST_VIDEO_PATH is not a readable file.") + + settings = Settings( + _env_file=None, + tiktok_client_key=str(client_key), + tiktok_client_secret=str(client_secret), + tiktok_redirect_uri=str(redirect_uri), + tiktok_direct_post_enabled=True, + max_upload_size=max(media_path.stat().st_size, 1_048_576), + whisper_model="tiny", + ) + provider = TikTokProvider(settings) + try: + account = await provider.get_account({"access_token": str(token)}) + assert account["external_account_id"] + creator = await provider.get_publish_options({"access_token": str(token)}) + if "SELF_ONLY" not in creator["privacy_level_options"]: + pytest.skip("Dedicated TikTok creator does not currently allow SELF_ONLY posts.") + probe = await FFprobeService(settings).probe(media_path) + metadata = TikTokPostMetadata.model_validate( + { + "title": "MediaRouter Phase 4C integration verification", + "privacy_level": "SELF_ONLY", + "disable_comment": True, + "disable_duet": True, + "disable_stitch": True, + "brand_content_toggle": False, + "brand_organic_toggle": False, + "is_aigc": False, + "music_usage_confirmed": True, + } + ) + state: dict[str, object] = {} + + async def persist(value: dict[str, object]) -> None: + state.clear() + state.update(value) + + uploaded = await provider.upload_media( + {"access_token": str(token)}, + { + "path": media_path, + "mime_type": "video/mp4", + "file_size": media_path.stat().st_size, + "probe": probe, + "tiktok_post_info": metadata.to_post_info(), + "provider_state": state, + "persist_provider_state": persist, + }, + ) + publish_id = str(uploaded["id"]) + terminal: dict[str, object] | None = None + for _ in range(60): + status = await provider.get_publish_status( + {"access_token": str(token)}, publish_id + ) + if status["status"] in {"published", "failed", "unavailable"}: + terminal = status + break + import asyncio + + await asyncio.sleep(10) + assert terminal is not None + assert terminal["status"] == "published" + public_ids = terminal.get("metadata", {}).get("public_post_ids", []) + granted = { + value + for value in os.getenv("TIKTOK_TEST_GRANTED_SCOPES", "").replace(",", " ").split() + if value + } + if "video.list" in granted and public_ids: + metrics = await provider.get_metrics( + {"access_token": str(token)}, str(public_ids[0]) + ) + assert metrics["status"] in {"available", "unavailable"} + assert not provider.capabilities.delete_post + finally: + await provider.close() diff --git a/tests/test_tiktok_publishing.py b/tests/test_tiktok_publishing.py new file mode 100644 index 0000000000000000000000000000000000000000..dda8a4631b2bab10b0ee213637417fee180be64b --- /dev/null +++ b/tests/test_tiktok_publishing.py @@ -0,0 +1,553 @@ +"""Phase 4B TikTok Direct Post coverage using only mocked official endpoints.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 + +import httpx +import pytest +from pydantic import ValidationError + +from app.container import build_container +from app.core.config import Settings +from app.social.domain.errors import ( + SocialAccountNotFoundError, + SocialCapabilityUnsupportedError, + SocialIdempotencyConflictError, + SocialMediaInvalidError, + SocialPermissionDeniedError, + SocialPostNotFoundError, + SocialProviderUnavailableError, + SocialPublishFailedError, + SocialRateLimitedError, + SocialReauthRequiredError, +) +from app.social.models import SocialAccount, SocialMediaAsset +from app.social.providers.tiktok import TikTokProvider +from app.social.schemas.posts import SocialPostCreate +from app.social.schemas.tiktok import TikTokPostMetadata +from app.social.workers.publisher import SocialPublisher + + +def publishing_settings(tmp_path: Path, **overrides: object) -> Settings: + values: dict[str, object] = { + "_env_file": None, + "auth_enabled": False, + "database_url": f"sqlite+aiosqlite:///{tmp_path / 'security.db'}", + "social_database_url": f"sqlite+aiosqlite:///{tmp_path / 'social.db'}", + "social_auto_migrate": True, + "social_worker_enabled": False, + "social_oauth_encryption_key": "test-only-encryption-material", + "tiktok_client_key": "tiktok-client-key", + "tiktok_client_secret": "tiktok-client-secret", + "tiktok_redirect_uri": ( + "https://api.example.com/v1/social/accounts/tiktok/callback" + ), + "tiktok_direct_post_enabled": True, + "tiktok_upload_chunk_bytes": 5_000_000, + "temp_dir": tmp_path / "temp", + "output_dir": tmp_path / "outputs", + "cleanup_interval_seconds": 3600, + "whisper_model": "tiny", + } + values.update(overrides) + return Settings(**values) + + +def valid_probe(*, duration: float = 15.0) -> dict[str, object]: + return { + "container": "mov,mp4,m4a,3gp,3g2,mj2", + "duration": duration, + "fps": 30.0, + "resolution": {"width": 1080, "height": 1920}, + "video_streams": [{"codec": "h264"}], + "audio_streams": [{"codec": "aac"}], + } + + +def valid_metadata(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "title": "A production-safe TikTok post", + "privacy_level": "SELF_ONLY", + "disable_comment": False, + "disable_duet": False, + "disable_stitch": False, + "brand_content_toggle": False, + "brand_organic_toggle": False, + "is_aigc": False, + "music_usage_confirmed": True, + } + values.update(overrides) + return values + + +async def test_direct_post_capabilities_are_fail_closed_and_approval_gated( + tmp_path: Path, +) -> None: + disabled = TikTokProvider( + publishing_settings(tmp_path, tiktok_direct_post_enabled=False) + ) + enabled = TikTokProvider(publishing_settings(tmp_path)) + try: + assert not disabled.capabilities.direct_publish + assert not disabled.capabilities.video_upload + assert disabled.capabilities.publishing_required_scopes == [] + assert enabled.capabilities.direct_publish + assert enabled.capabilities.video_upload + assert enabled.capabilities.video_status + assert enabled.capabilities.scheduled_publish + assert not enabled.capabilities.native_scheduling + assert not enabled.capabilities.delete_post + assert enabled.capabilities.publishing_required_scopes == ["video.publish"] + with pytest.raises(SocialCapabilityUnsupportedError): + await enabled.delete_post({"access_token": "access-token"}, "post-id") + finally: + await disabled.close() + await enabled.close() + + +async def test_publishing_oauth_scope_is_requested_only_by_explicit_elevation( + tmp_path: Path, +) -> None: + provider = TikTokProvider(publishing_settings(tmp_path)) + try: + connection_url = await provider.get_authorization_url( + state="s" * 43, + redirect_uri=provider.redirect_uri, + ) + publishing_url = await provider.get_authorization_url( + state="s" * 43, + redirect_uri=provider.redirect_uri, + additional_scopes=["video.publish"], + ) + finally: + await provider.close() + assert parse_qs(urlparse(connection_url).query)["scope"] == ["user.info.basic"] + assert parse_qs(urlparse(publishing_url).query)["scope"] == [ + "user.info.basic,video.publish" + ] + + +async def test_direct_post_queries_creator_initializes_streams_and_reconciles( + tmp_path: Path, +) -> None: + video = tmp_path / "video.mp4" + video.write_bytes(b"streamed-tiktok-video") + calls: list[str] = [] + persisted: list[dict[str, object]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + calls.append(f"{request.method} {request.url.path}") + if request.url.path.endswith("/creator_info/query/"): + assert request.headers["authorization"] == "Bearer access-token" + return httpx.Response(200, json={ + "data": { + "privacy_level_options": ["SELF_ONLY", "PUBLIC_TO_EVERYONE"], + "comment_disabled": False, + "duet_disabled": False, + "stitch_disabled": False, + "max_video_post_duration_sec": 300, + }, + "error": {"code": "ok", "message": ""}, + }) + if request.url.path.endswith("/video/init/"): + payload = json.loads(request.content) + assert payload["source_info"] == { + "source": "FILE_UPLOAD", + "video_size": video.stat().st_size, + "chunk_size": video.stat().st_size, + "total_chunk_count": 1, + } + assert payload["post_info"]["privacy_level"] == "SELF_ONLY" + assert "music_usage_confirmed" not in payload["post_info"] + return httpx.Response(200, json={ + "data": { + "publish_id": "publish-id", + "upload_url": "https://open-upload.tiktokapis.com/video/session", + }, + "error": {"code": "ok", "message": ""}, + }) + if request.method == "PUT": + assert request.headers["content-range"] == ( + f"bytes 0-{video.stat().st_size - 1}/{video.stat().st_size}" + ) + assert request.content == video.read_bytes() + return httpx.Response(201) + if request.url.path.endswith("/status/fetch/"): + return httpx.Response(200, json={ + "data": { + "status": "PUBLISH_COMPLETE", + "publicaly_available_post_id": ["public-video-id"], + "uploaded_bytes": video.stat().st_size, + }, + "error": {"code": "ok", "message": ""}, + }) + raise AssertionError(f"Unexpected request {request.method} {request.url}") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(publishing_settings(tmp_path), http_client=client) + + async def persist(value: dict[str, object]) -> None: + persisted.append(dict(value)) + + try: + uploaded = await provider.upload_media( + {"access_token": "access-token"}, + { + "path": video, + "mime_type": "video/mp4", + "file_size": video.stat().st_size, + "probe": valid_probe(), + "tiktok_post_info": TikTokPostMetadata.model_validate( + valid_metadata() + ).to_post_info(), + "persist_provider_state": persist, + }, + ) + published = await provider.publish( + {"access_token": "access-token"}, {"upload": uploaded} + ) + status = await provider.get_publish_status( + {"access_token": "access-token"}, "publish-id" + ) + finally: + await client.aclose() + + assert uploaded == {"id": "publish-id"} + assert published == uploaded + assert status["status"] == "published" + assert status["metadata"]["public_post_ids"] == ["public-video-id"] + assert persisted[0] == { + "tiktok_init_started": True, + "tiktok_video_size": video.stat().st_size, + } + assert persisted[-1]["tiktok_uploaded_bytes"] == video.stat().st_size + assert calls == [ + "POST /v2/post/publish/creator_info/query/", + "POST /v2/post/publish/video/init/", + "PUT /video/session", + "POST /v2/post/publish/status/fetch/", + ] + + +async def test_media_validation_rejects_incompatible_video_before_provider_call( + tmp_path: Path, +) -> None: + video = tmp_path / "video.avi" + video.write_bytes(b"invalid") + client = httpx.AsyncClient( + transport=httpx.MockTransport( + lambda request: pytest.fail(f"Unexpected provider call {request.url}") + ) + ) + provider = TikTokProvider(publishing_settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialMediaInvalidError): + await provider.validate_media({ + "path": video, + "mime_type": "video/x-msvideo", + "file_size": video.stat().st_size, + "probe": { + **valid_probe(), + "container": "avi", + "video_streams": [{"codec": "mpeg4"}], + }, + }) + finally: + await client.aclose() + + +@pytest.mark.parametrize( + ("provider_status", "expected"), + [ + ("PROCESSING_UPLOAD", "processing"), + ("PROCESSING_DOWNLOAD", "processing"), + ("SEND_TO_USER_INBOX", "processing"), + ("PUBLISH_COMPLETE", "published"), + ("FAILED", "failed"), + ("UNKNOWN_PROVIDER_STATE", "unavailable"), + ], +) +async def test_tiktok_status_reconciliation_normalizes_official_states( + tmp_path: Path, provider_status: str, expected: str +) -> None: + async def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "data": {"status": provider_status, "fail_reason": "internal"}, + "error": {"code": "ok"}, + }) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(publishing_settings(tmp_path), http_client=client) + try: + result = await provider.get_publish_status( + {"access_token": "access-token"}, "publish-id" + ) + finally: + await client.aclose() + assert result["status"] == expected + + +async def test_tiktok_metadata_and_chunk_planning_enforce_current_contract( + tmp_path: Path, +) -> None: + with pytest.raises(ValidationError): + TikTokPostMetadata.model_validate( + valid_metadata(music_usage_confirmed=False) + ) + with pytest.raises(ValidationError): + TikTokPostMetadata.model_validate( + valid_metadata(title="\U0001f600" * 1101) + ) + provider = TikTokProvider(publishing_settings(tmp_path)) + try: + assert provider._chunk_plan(4_000_000) == (4_000_000, 1) + assert provider._chunk_plan(70_000_000) == (5_000_000, 14) + finally: + await provider.close() + + +async def test_unknown_init_outcome_never_creates_a_second_tiktok_post( + tmp_path: Path, +) -> None: + video = tmp_path / "video.mp4" + video.write_bytes(b"video") + init_calls = 0 + durable_state: dict[str, object] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal init_calls + if request.url.path.endswith("/creator_info/query/"): + return httpx.Response(200, json={ + "data": { + "privacy_level_options": ["SELF_ONLY"], + "comment_disabled": False, + "duet_disabled": False, + "stitch_disabled": False, + "max_video_post_duration_sec": 300, + }, + "error": {"code": "ok"}, + }) + if request.url.path.endswith("/video/init/"): + init_calls += 1 + return httpx.Response(200, json={ + "data": { + "publish_id": "accepted-but-not-durable", + "upload_url": "https://open-upload.tiktokapis.com/video/session", + }, + "error": {"code": "ok"}, + }) + raise AssertionError("No upload is safe after provider-state persistence fails") + + async def fail_after_marker(value: dict[str, object]) -> None: + if "tiktok_publish_id" in value: + raise RuntimeError("simulated database outage") + durable_state.clear() + durable_state.update(value) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = TikTokProvider(publishing_settings(tmp_path), http_client=client) + media = { + "path": video, + "mime_type": "video/mp4", + "file_size": video.stat().st_size, + "probe": valid_probe(), + "tiktok_post_info": TikTokPostMetadata.model_validate( + valid_metadata() + ).to_post_info(), + "persist_provider_state": fail_after_marker, + } + try: + with pytest.raises(RuntimeError): + await provider.upload_media( + {"access_token": "access-token"}, media + ) + with pytest.raises(SocialPublishFailedError) as raised: + await provider.upload_media( + {"access_token": "access-token"}, + {**media, "provider_state": durable_state}, + ) + finally: + await client.aclose() + assert init_calls == 1 + assert "duplicate publishing was prevented" in str(raised.value) + + +@pytest.mark.parametrize( + ("status_code", "error_code", "exception_type"), + [ + (401, "access_token_expired", SocialReauthRequiredError), + (403, "scope_not_authorized", SocialPermissionDeniedError), + (429, "rate_limit_exceeded", SocialRateLimitedError), + (500, "internal_error", SocialProviderUnavailableError), + (400, "invalid_file_upload", SocialMediaInvalidError), + ], +) +async def test_tiktok_error_normalization_is_safe_and_retry_classifiable( + tmp_path: Path, + status_code: int, + error_code: str, + exception_type: type[Exception], +) -> None: + secret = "token-that-must-not-leak" + client = httpx.AsyncClient(transport=httpx.MockTransport( + lambda request: httpx.Response( + status_code, + json={"error": {"code": error_code, "message": secret}}, + ) + )) + provider = TikTokProvider(publishing_settings(tmp_path), http_client=client) + try: + with pytest.raises(exception_type) as raised: + await provider.get_publish_options({"access_token": secret}) + finally: + await client.aclose() + assert secret not in str(raised.value) + + +async def test_tiktok_worker_lifecycle_idempotency_scope_and_workspace_isolation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + container = build_container(publishing_settings(tmp_path)) + await container.social.initialize() + workspace = "workspace-tiktok" + other_workspace = "workspace-other" + request_id = str(uuid4()) + output = container.settings.output_dir / request_id + output.mkdir(parents=True, exist_ok=True) + video = output / "video.mp4" + video.write_bytes(b"video") + account = await container.social.accounts.repository.create(SocialAccount( + workspace_id=workspace, + provider="tiktok", + account_type="creator", + external_account_id="creator-open-id", + display_name="Creator", + status="connected", + )) + asset = await container.social.media_assets.repository.create(SocialMediaAsset( + workspace_id=workspace, + request_id=request_id, + filename=video.name, + mime_type="video/mp4", + file_size=video.stat().st_size, + metadata_json=valid_probe(), + )) + await container.social.accounts.tokens.store( + workspace, + account.id, + {"access_token": "encrypted-token", "refresh_token": "encrypted-refresh"}, + scopes=["user.info.basic", "video.publish"], + ) + adapter = container.social.accounts.providers.get("tiktok") + statuses = iter([ + {"id": "publish-id", "status": "processing", "metadata": {"provider_status": "PROCESSING_UPLOAD"}}, + {"id": "publish-id", "status": "published", "metadata": {"provider_status": "PUBLISH_COMPLETE"}}, + ]) + monkeypatch.setattr(adapter, "validate_media", AsyncMock(return_value=None)) + monkeypatch.setattr(adapter, "upload_media", AsyncMock(return_value={"id": "publish-id"})) + monkeypatch.setattr(adapter, "publish", AsyncMock(return_value={"id": "publish-id"})) + monkeypatch.setattr(adapter, "get_publish_status", AsyncMock(side_effect=lambda *_: next(statuses))) + + async def resolve(*_: object, **__: object) -> dict[str, object]: + return { + "path": video, + "mime_type": "video/mp4", + "file_size": video.stat().st_size, + "probe": valid_probe(), + } + + monkeypatch.setattr(container.social.media_assets, "resolve_for_publish", resolve) + payload = SocialPostCreate.model_validate({ + "media_asset_id": asset.id, + "publish_mode": "now", + "targets": [{ + "social_account_id": account.id, + "caption": {"caption": "TikTok caption"}, + "tiktok": valid_metadata(), + }], + }) + try: + post = await container.social.publishing.create( + workspace_id=workspace, + user_id="user", + payload=payload, + idempotency_key="one-logical-publish", + ) + replay = await container.social.publishing.create( + workspace_id=workspace, + user_id="user", + payload=payload, + idempotency_key="one-logical-publish", + ) + assert replay.id == post.id + with pytest.raises(SocialIdempotencyConflictError): + await container.social.publishing.create( + workspace_id=workspace, + user_id="user", + payload=SocialPostCreate.model_validate({ + **payload.model_dump(mode="json"), + "targets": [{ + **payload.targets[0].model_dump(mode="json"), + "tiktok": valid_metadata(title="different"), + }], + }), + idempotency_key="one-logical-publish", + ) + job = (await container.social.jobs.repository.list_for_post( + workspace, post.id + ))[0] + worker = SocialPublisher(container.social) + await worker.process(workspace, job.id) + processing = await container.social.jobs.get(workspace, job.id) + assert processing.status == "publishing" + await worker.process(workspace, job.id) + published = await container.social.jobs.get(workspace, job.id) + assert published.status == "published" + assert adapter.upload_media.await_count == 1 + with pytest.raises(SocialPostNotFoundError): + await container.social.publishing.get(other_workspace, post.id) + with pytest.raises(SocialAccountNotFoundError): + await container.social.publishing.publish_options( + other_workspace, account.id + ) + with pytest.raises(SocialMediaInvalidError): + await container.social.media_assets.repository.get( + other_workspace, asset.id + ) + finally: + await container.social.close() + await container.security_database.close() + + +async def test_tiktok_publish_requires_explicit_video_publish_scope( + tmp_path: Path, +) -> None: + container = build_container(publishing_settings(tmp_path)) + await container.social.initialize() + account = await container.social.accounts.repository.create(SocialAccount( + workspace_id="workspace", + provider="tiktok", + account_type="creator", + external_account_id="open-id", + status="connected", + )) + await container.social.accounts.tokens.store( + "workspace", + account.id, + {"access_token": "foundation-only"}, + scopes=["user.info.basic"], + ) + try: + with pytest.raises(SocialPermissionDeniedError): + await container.social.publishing.publish_options( + "workspace", account.id + ) + finally: + await container.social.close() + await container.security_database.close() diff --git a/tests/test_youtube_live.py b/tests/test_youtube_live.py new file mode 100644 index 0000000000000000000000000000000000000000..d1cef25dc483022deaee16349841099117114dd5 --- /dev/null +++ b/tests/test_youtube_live.py @@ -0,0 +1,109 @@ +"""Opt-in, destructive YouTube Data API integration test. + +OAuth browser consent remains an operator action. The access token supplied +to this test must therefore come from the staging channel after that consent +flow. CI never runs this module implicitly and the test refuses to upload +unless explicit cleanup has been requested. +""" + +from __future__ import annotations + +import os + +import pytest + + +pytestmark = pytest.mark.skipif( + os.getenv("RUN_YOUTUBE_INTEGRATION_TESTS") != "true", + reason="YouTube live integration is NOT VERIFIED; set RUN_YOUTUBE_INTEGRATION_TESTS=true with staging credentials.", +) + + +def _require_live_configuration() -> None: + required = ( + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "YOUTUBE_LIVE_TEST_ACCESS_TOKEN", + "YOUTUBE_LIVE_TEST_MEDIA_PATH", + ) + missing = [name for name in required if not os.getenv(name)] + if missing: + pytest.skip(f"YouTube live integration is NOT VERIFIED; missing {', '.join(missing)}") + if os.getenv("YOUTUBE_LIVE_TEST_DELETE") != "true": + pytest.skip("Set YOUTUBE_LIVE_TEST_DELETE=true to permit cleanup of the staged test video.") + + +def test_youtube_live_configuration_is_explicit() -> None: + """Protect the opt-in switch from accidentally becoming an implicit test.""" + assert os.getenv("RUN_YOUTUBE_INTEGRATION_TESTS") == "true" + _require_live_configuration() + + +@pytest.mark.asyncio +async def test_youtube_live_channel_upload_status_metrics_and_delete() -> None: + """Exercise the official API against an operator-provisioned staging grant. + + The access token is never printed or returned. The test discovers the + channel, streams a real local test asset through the resumable endpoint, + reconciles the normalized status and statistics, then deletes the video. + """ + # Imports stay inside the opt-in test so a normal collection on a minimal + # machine does not need the backend's dependency set. + _require_live_configuration() + from pathlib import Path + + from app.core.config import Settings + from app.services.ffprobe_service import FFprobeService + from app.services.validator import MediaValidator + from app.social.providers.youtube import YouTubeProvider + from app.social.schemas.youtube import YouTubePostMetadata + + path = Path(os.environ["YOUTUBE_LIVE_TEST_MEDIA_PATH"]).expanduser().resolve() + if not path.is_file(): + pytest.skip("YOUTUBE_LIVE_TEST_MEDIA_PATH does not point to a readable staged video.") + settings = Settings( + _env_file=None, + auth_enabled=False, + google_client_id=os.environ["GOOGLE_CLIENT_ID"], + google_client_secret=os.environ["GOOGLE_CLIENT_SECRET"], + ) + provider = YouTubeProvider(settings) + token: dict[str, object] = {"access_token": os.environ["YOUTUBE_LIVE_TEST_ACCESS_TOKEN"]} + if refresh_token := os.getenv("YOUTUBE_LIVE_TEST_REFRESH_TOKEN"): + token["refresh_token"] = refresh_token + refreshed = await provider.refresh_token(token) + token = {**token, **refreshed} + + uploaded_id: str | None = None + try: + account = await provider.get_account(token) + assert account["external_account_id"] + probe = await FFprobeService(settings).probe(path) + mime_type = MediaValidator(settings).infer_mime(path) + metadata = YouTubePostMetadata( + title="MediaRouter YouTube integration test", + description="Automatically deleted staging verification video.", + privacy_status="private", + made_for_kids=False, + notify_subscribers=False, + ) + uploaded = await provider.upload_media( + token, + { + "path": path, + "mime_type": mime_type, + "file_size": path.stat().st_size, + "probe": probe, + "youtube_resource": metadata.to_youtube_resource(), + "notify_subscribers": False, + }, + ) + uploaded_id = str(uploaded["id"]) + status = await provider.get_publish_status(token, uploaded_id) + assert status["status"] in {"processing", "published"} + metrics = await provider.get_metrics(token, uploaded_id) + assert metrics["status"] in {"available", "unavailable"} + finally: + if uploaded_id: + await provider.delete_post(token, uploaded_id) + await provider.close() diff --git a/tests/test_youtube_provider.py b/tests/test_youtube_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..3b3642b4f427b3488aa7354549f53a3d34d2038b --- /dev/null +++ b/tests/test_youtube_provider.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from pydantic import ValidationError + +from app.core.config import Settings +from app.social.domain.errors import ( + SocialMediaInvalidError, + SocialPermissionDeniedError, + SocialReauthRequiredError, +) +from app.social.providers.youtube import YouTubeProvider +from app.social.schemas.youtube import YouTubePostMetadata + + +def settings(tmp_path: Path) -> Settings: + return Settings( + _env_file=None, + auth_enabled=False, + google_client_id="google-client-id", + google_client_secret="google-client-secret", + social_oauth_encryption_key="test-only-encryption-material", + temp_dir=tmp_path / "temp", + output_dir=tmp_path / "outputs", + youtube_upload_chunk_bytes=262_144, + whisper_model="tiny", + ) + + +def metadata() -> YouTubePostMetadata: + return YouTubePostMetadata( + title="MediaRouter test video", + description="A test upload", + tags=["mediarouter", "test"], + privacy_status="private", + made_for_kids=False, + ) + + +async def test_youtube_authorization_url_requests_minimum_scope_and_pkce(tmp_path: Path) -> None: + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(500))) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + url = await provider.get_authorization_url( + state="s" * 32, + redirect_uri="https://api.example/v1/social/accounts/youtube/callback", + code_challenge="challenge", + ) + finally: + await client.aclose() + parsed = parse_qs(urlparse(url).query) + assert parsed["scope"] == ["https://www.googleapis.com/auth/youtube.upload"] + assert parsed["code_challenge"] == ["challenge"] + assert parsed["code_challenge_method"] == ["S256"] + assert parsed["access_type"] == ["offline"] + + +async def test_youtube_exchange_refresh_and_channel_discovery(tmp_path: Path) -> None: + received_verifier = False + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal received_verifier + if request.url.path == "/token": + form = request.content.decode() + if "grant_type=authorization_code" in form: + received_verifier = "code_verifier=verifier" in form + return httpx.Response(200, json={"access_token": "access", "refresh_token": "refresh", "expires_in": 3600, "scope": "https://www.googleapis.com/auth/youtube.upload", "token_type": "Bearer"}) + return httpx.Response(200, json={"access_token": "refreshed", "expires_in": 3600, "token_type": "Bearer"}) + if request.url.path.endswith("/channels"): + return httpx.Response(200, json={"items": [{"id": "UC-stable-channel", "snippet": {"title": "Test Channel", "customUrl": "@test", "thumbnails": {"high": {"url": "https://img.example/avatar.jpg"}}}}]}) + raise AssertionError(f"Unexpected request {request.method} {request.url}") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + exchanged = await provider.exchange_code(code="code", redirect_uri="https://api.example/callback", code_verifier="verifier") + refreshed = await provider.refresh_token(exchanged) + account = await provider.get_account(exchanged) + finally: + await client.aclose() + assert exchanged["access_token"] == "access" + assert received_verifier + assert refreshed["access_token"] == "refreshed" + assert account["external_account_id"] == "UC-stable-channel" + assert account["username"] == "@test" + assert "email" not in account + + +async def test_youtube_resumable_upload_streams_file_and_returns_video_id(tmp_path: Path) -> None: + media = tmp_path / "video.mp4" + media.write_bytes(b"video-bytes") + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + if request.method == "POST" and request.url.path == "/upload/youtube/v3/videos": + return httpx.Response(200, headers={"Location": "https://www.googleapis.com/upload/youtube/v3/videos?upload_id=session"}) + if request.method == "PUT": + assert request.headers["Content-Range"] == f"bytes 0-{media.stat().st_size - 1}/{media.stat().st_size}" + return httpx.Response(200, json={"id": "yt-video-id"}) + raise AssertionError(f"Unexpected request {request.method} {request.url}") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + sessions: list[str | None] = [] + try: + result = await provider.upload_media( + {"access_token": "access"}, + { + "path": media, + "mime_type": "video/mp4", + "file_size": media.stat().st_size, + "probe": { + "container": "mov,mp4,m4a,3gp,3g2,mj2", + "duration": 1.0, + "resolution": {"width": 1280, "height": 720}, + "video_streams": [{"codec": "h264"}], + }, + "youtube_resource": metadata().to_youtube_resource(), + "persist_upload_session": sessions.append, + }, + ) + finally: + await client.aclose() + assert result == {"id": "yt-video-id", "url": "https://www.youtube.com/watch?v=yt-video-id"} + assert sessions == ["https://www.googleapis.com/upload/youtube/v3/videos?upload_id=session"] + assert len(requests) == 2 + + +async def test_youtube_resumable_upload_reconciles_a_retry_without_restarting(tmp_path: Path) -> None: + media = tmp_path / "video.mp4" + media.write_bytes(b"video-bytes") + chunk_attempts = 0 + session_queries = 0 + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal chunk_attempts, session_queries + if request.method == "POST": + return httpx.Response(200, headers={"Location": "https://www.googleapis.com/upload/youtube/v3/videos?upload_id=session"}) + content_range = request.headers.get("Content-Range") + if content_range == f"bytes */{media.stat().st_size}": + session_queries += 1 + return httpx.Response(200, json={"id": "yt-reconciled-video"}) + chunk_attempts += 1 + return httpx.Response(503, json={"error": {"errors": [{"reason": "backendError"}]}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + result = await provider.upload_media( + {"access_token": "access"}, + { + "path": media, + "mime_type": "video/mp4", + "file_size": media.stat().st_size, + "probe": { + "container": "mov,mp4,m4a,3gp,3g2,mj2", + "duration": 1.0, + "resolution": {"width": 1280, "height": 720}, + "video_streams": [{"codec": "h264"}], + }, + "youtube_resource": metadata().to_youtube_resource(), + }, + ) + finally: + await client.aclose() + assert result["id"] == "yt-reconciled-video" + assert chunk_attempts == 1 + assert session_queries == 1 + + +async def test_youtube_rejects_invalid_media_before_creating_an_upload_session(tmp_path: Path) -> None: + media = tmp_path / "audio.mp3" + media.write_bytes(b"not-a-video") + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(500))) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialMediaInvalidError): + await provider.validate_media( + { + "path": media, + "mime_type": "audio/mpeg", + "file_size": media.stat().st_size, + "probe": {}, + } + ) + finally: + await client.aclose() + + +async def test_youtube_status_deletion_and_public_video_metrics(tmp_path: Path) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE": + return httpx.Response(204) + if request.url.params.get("part") == "snippet,status,processingDetails": + return httpx.Response(200, json={"items": [{"id": "video", "snippet": {"publishedAt": "2030-01-01T00:00:00Z"}, "status": {"uploadStatus": "processed", "privacyStatus": "unlisted"}, "processingDetails": {"processingStatus": "succeeded"}}]}) + if request.url.params.get("part") == "statistics,snippet,status": + return httpx.Response(200, json={"items": [{"id": "video", "snippet": {"publishedAt": "2030-01-01T00:00:00Z"}, "statistics": {"viewCount": "7", "likeCount": "2", "commentCount": "1"}}]}) + raise AssertionError(f"Unexpected request {request.method} {request.url}") + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + status = await provider.get_publish_status({"access_token": "access"}, "video") + metrics = await provider.get_metrics({"access_token": "access"}, "video") + await provider.delete_post({"access_token": "access"}, "video") + finally: + await client.aclose() + assert status["status"] == "published" + assert metrics["views"] == 7 + assert metrics["comments"] == 1 + + +async def test_youtube_provider_normalizes_auth_and_permission_errors(tmp_path: Path) -> None: + async def unauthorized(_: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": {"message": "do not leak"}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(unauthorized)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialReauthRequiredError): + await provider.get_publish_status({"access_token": "access"}, "video") + finally: + await client.aclose() + + async def forbidden(_: httpx.Request) -> httpx.Response: + return httpx.Response(403, json={"error": {"errors": [{"reason": "forbidden"}]}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(forbidden)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialPermissionDeniedError): + await provider.delete_post({"access_token": "access"}, "video") + finally: + await client.aclose() + + +async def test_youtube_rejects_a_pkce_mismatch_without_exposing_google_details(tmp_path: Path) -> None: + async def rejected(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + json={"error": {"message": "PKCE verifier did not match", "errors": []}}, + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(rejected)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + with pytest.raises(SocialReauthRequiredError) as raised: + await provider.exchange_code( + code="code", + redirect_uri="https://api.example/callback", + code_verifier="wrong-verifier", + ) + finally: + await client.aclose() + assert "PKCE verifier" not in str(raised.value) + + +async def test_youtube_status_reconciliation_reports_processing_and_failed(tmp_path: Path) -> None: + responses = iter( + [ + {"items": [{"id": "video", "status": {"uploadStatus": "uploaded"}, "processingDetails": {"processingStatus": "processing"}}]}, + {"items": [{"id": "video", "status": {"uploadStatus": "failed"}, "processingDetails": {"processingStatus": "failed", "processingFailureReason": "transcodeFailed"}}]}, + ] + ) + + async def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=next(responses)) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = YouTubeProvider(settings(tmp_path), http_client=client) + try: + processing = await provider.get_publish_status({"access_token": "access"}, "video") + failed = await provider.get_publish_status({"access_token": "access"}, "video") + finally: + await client.aclose() + assert processing["status"] == "processing" + assert failed["status"] == "failed" + assert failed["metadata"]["failure_reason"] == "transcodeFailed" + + +def test_youtube_metadata_requires_explicit_policy_declaration() -> None: + with pytest.raises(ValidationError): + YouTubePostMetadata.model_validate({"title": "No audience declaration"}) + with pytest.raises(ValidationError): + YouTubePostMetadata.model_validate( + {"title": "Invalid scheduled status", "made_for_kids": False, "privacy_status": "public", "scheduled_publish_at": "2030-01-01T00:00:00Z"} + )