Files
mono/.claude/plans/unified-inventing-kay.md
giles 094b6c55cd Fix AP blueprint cross-DB queries + harden Ghost sync init
AP blueprints (activitypub.py, ap_social.py) were querying federation
tables (ap_actor_profiles etc.) on g.s which points to the app's own DB
after the per-app split. Now uses g._ap_s backed by get_federation_session()
for non-federation apps.

Also hardens Ghost sync before_app_serving to catch/rollback on failure
instead of crashing the Hypercorn worker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:06:42 +00:00

172 lines
7.5 KiB
Markdown

# Social Network Sharing Integration
## Context
Rose Ash already has ActivityPub for federated social sharing. This plan adds OAuth-based sharing to mainstream social networks — Facebook, Instagram, Threads, Twitter/X, LinkedIn, and Mastodon. Users connect their social accounts via the account dashboard, then manually share content (blog posts, events, products) via a share button on content pages.
All social logic lives in the **account** microservice. Content apps get a share button that opens the account share page.
---
## Phase 1: Data Model + Encryption
### 1a. `shared/models/social_connection.py` (NEW)
- SQLAlchemy 2.0 model following `oauth_grant.py` pattern
- Table `social_connections` in db_account
- Columns: `id`, `user_id` (FK to users.id with CASCADE), `platform` (facebook/instagram/threads/twitter/linkedin/mastodon), `platform_user_id`, `platform_username`, `display_name`, `access_token_enc`, `refresh_token_enc`, `token_expires_at`, `scopes`, `extra_data` (JSONB — mastodon instance URL, facebook page ID, etc.), `created_at`, `updated_at`, `revoked_at`
- Indexes: `(user_id, platform)`, unique `(platform, platform_user_id)`
### 1b. `shared/models/__init__.py` (MODIFY)
- Add `from .social_connection import SocialConnection`
### 1c. `shared/infrastructure/social_crypto.py` (NEW)
- Fernet encrypt/decrypt using `SOCIAL_ENCRYPTION_KEY` env var
- `encrypt_token(plaintext) -> str`, `decrypt_token(ciphertext) -> str`
### 1d. Alembic migration (NEW)
- Creates `social_connections` table
### 1e. `docker-compose.yml` (MODIFY)
- Add to `x-app-env`: `SOCIAL_ENCRYPTION_KEY`, plus per-platform credentials (`SOCIAL_FACEBOOK_APP_ID`, `SOCIAL_FACEBOOK_APP_SECRET`, `SOCIAL_TWITTER_CLIENT_ID`, `SOCIAL_TWITTER_CLIENT_SECRET`, `SOCIAL_LINKEDIN_CLIENT_ID`, `SOCIAL_LINKEDIN_CLIENT_SECRET`)
---
## Phase 2: Platform OAuth Clients
All in `account/services/social_platforms/`:
### 2a. `base.py` (NEW)
- `OAuthResult` dataclass (platform_user_id, tokens, expiry, extra_data)
- `ShareResult` dataclass (success, platform_post_id, platform_post_url, error)
- `SocialPlatform` abstract base class: `get_authorize_url()`, `exchange_code()`, `refresh_access_token()`, `share_link()`, `verify_token()`
### 2b. `meta.py` (NEW) — Facebook + Instagram + Threads
- **Facebook**: OAuth2 via Graph API, `pages_manage_posts` scope, exchange user token → long-lived → page token, post via `/{page_id}/feed`
- **Instagram**: Same Meta OAuth, `instagram_basic` + `instagram_content_publish` scopes, business/creator accounts only, container → publish workflow
- **Threads**: Separate OAuth at threads.net, `threads_basic` + `threads_content_publish` scopes, container → publish
### 2c. `twitter.py` (NEW) — Twitter/X
- OAuth 2.0 with PKCE, `tweet.write` + `offline.access` scopes
- Post via `POST https://api.twitter.com/2/tweets`
### 2d. `linkedin.py` (NEW) — LinkedIn
- OAuth 2.0, `w_member_social` + `openid` scopes
- Post via LinkedIn Posts API
### 2e. `mastodon.py` (NEW) — Mastodon
- Dynamic app registration per instance (`POST /api/v1/apps`)
- OAuth 2.0, `write:statuses` scope
- Post via `POST /api/v1/statuses`
- Instance URL stored in `extra_data["instance_url"]`
### 2f. `__init__.py` (NEW) — Platform registry
- `PLATFORMS` dict, lazy-initialized from env vars
- Mastodon always available (no pre-configured credentials)
- `get_platform(name)`, `available_platforms()`
---
## Phase 3: Account Social Blueprint
### 3a. `account/bp/social/__init__.py` (NEW)
### 3b. `account/bp/social/routes.py` (NEW)
Routes (all require login):
- `GET /social/` — list connected accounts + available platforms
- `GET /social/connect/<platform>/` — start OAuth redirect (Mastodon: accept instance URL param)
- `GET /social/callback/<platform>/` — OAuth callback, exchange code, encrypt & store tokens
- `POST /social/disconnect/<int:id>/` — soft-delete (set revoked_at)
- `GET /social/share/` — share page (params: url, title, description, image)
- `POST /social/share/` — execute share to selected accounts, return results
OAuth state stored in session (nonce + platform + redirect params).
### 3c. `account/bp/__init__.py` (MODIFY)
- Add `from .social.routes import register as register_social_bp`
### 3d. `account/app.py` (MODIFY)
- Register social blueprint **before** account blueprint (account has catch-all `/<slug>/`)
```python
app.register_blueprint(register_auth_bp())
app.register_blueprint(register_social_bp()) # <-- NEW, before account
app.register_blueprint(register_account_bp())
app.register_blueprint(register_fragments())
```
### 3e. `account/templates/_types/auth/_nav.html` (MODIFY)
- Add "social" link between newsletters and `account_nav_html`
---
## Phase 4: Templates
### 4a. `account/templates/_types/auth/_social_panel.html` (NEW)
- Platform cards with icons (Font Awesome: `fa-facebook`, `fa-instagram`, `fa-threads`, `fa-x-twitter`, `fa-linkedin`, `fa-mastodon`)
- Connected accounts per platform: display name, username, disconnect button
- "Connect" button per platform
- Mastodon: instance URL input before connecting
### 4b. `account/templates/_types/auth/_share_panel.html` (NEW)
- Content preview card (title, image, URL)
- Connected accounts as checkboxes grouped by platform
- Optional message textarea
- Share button → HTMX POST to `/social/share/`
### 4c. `account/templates/_types/auth/_share_result.html` (NEW)
- Per-platform success/failure with links to created posts
### 4d. `account/templates/_types/auth/_mastodon_connect.html` (NEW)
- Instance URL input form
---
## Phase 5: Share Button in Content Apps
### 5a. `account/bp/fragments/routes.py` (MODIFY)
- Add `share-button` handler: accepts url, title, description, image params
- Returns a share icon/link pointing to `account.rose-ash.com/social/share/?url=...&title=...`
### 5b. `account/templates/fragments/share_button.html` (NEW)
- Small button: `<a href="..." target="_blank"><i class="fa-solid fa-share-nodes"></i> Share</a>`
### 5c. Content app integration
- Blog post detail: fetch `share-button` fragment from account, render in post template
- Events detail: same pattern
- Market product detail: same pattern
- Each passes its own public URL, title, description, image to the fragment
---
## Phase 6: Token Refresh + Share History
### 6a. Token refresh in share flow
- Before posting, check `token_expires_at`; if expired, call `refresh_access_token()`
- Update encrypted tokens in DB
- If refresh fails, mark connection with error and prompt reconnect
### 6b. `shared/models/social_share.py` (NEW, optional)
- Table `social_shares`: connection_id, shared_url, shared_title, platform_post_id, platform_post_url, status, error_message, created_at
- Prevents duplicate shares, enables "shared" indicator on content pages
---
## Key Patterns to Follow
| Pattern | Reference File |
|---------|---------------|
| ORM model (mapped_column, FK, indexes) | `shared/models/oauth_grant.py` |
| Blueprint registration + OOB template | `account/bp/account/routes.py` |
| Fragment handler dict | `account/bp/fragments/routes.py` |
| Account nav link | `account/templates/_types/auth/_nav.html` |
| httpx async client | `shared/infrastructure/actions.py` |
## Verification
1. Generate `SOCIAL_ENCRYPTION_KEY`, add to `.env`
2. Run Alembic migration
3. Start account app, navigate to `/social/`
4. Connect a test Mastodon account (easiest — no app review needed)
5. Navigate to a blog post, click Share, select Mastodon account, verify post appears
6. Disconnect account, verify soft-delete
7. Test token refresh by connecting Facebook with short-lived token