# Social connections (M2)

## Moving parts

| Piece | Where |
|---|---|
| Provider list, scopes, API versions | `config/social.php` |
| Versioned capability data | `config/social/capabilities/*.json` → `provider_capabilities` (`php artisan social:sync-capabilities`) |
| Adapters | `app/Domain/Social/Providers/*` (one per destination; Meta and Google share a base class) |
| Sandbox adapter | `FakeProvider` (only when `SOCIAL_FAKE_PROVIDERS=true` **and** `APP_ENV` is local/testing; the app refuses to boot otherwise) |
| Credentials | `platform_integrations` per environment (super admin → Integrations); secret in the SecretStore; env vars as a local fallback |
| Availability | `ProviderRegistry::availability()`: credentials → enabled → app review approved/not required |
| Per-account capability | `CapabilityResolver`: API support ∩ availability ∩ Pennant flag `provider.{key}` ∩ account health ∩ granted scopes |

## Connect flow

```
app (current workspace)           app (no workspace needed)            provider
GET /clients/{c}/connect/{p} ──►  (state hash + PKCE verifier stored in master oauth_states, 10 min, single use)
        └──────────────────────── redirect ─────────────────────────────► consent screen
                                  GET /oauth/{p}/callback ◄──────────────── code + state
                                   ├ consume state atomically (replay/expiry/provider mismatch → 400)
                                   ├ exchange code, discover destinations
                                   └ park encrypted result, one-time token (15 min)
GET /workspaces/{slug}/enter?next=/clients/{c}/connect/{p}/select?token=… ◄──── redirect
   member picks destinations + confirms consent → LinkSocialAccounts (tenant DB transaction)
```

- The callback never touches a tenant database. The tenant is restored from the state, which is bound to tenant, user, client and provider.
- Only the member who started the flow can use the parked result.
- Tokens are encrypted at rest with `TOKEN_ENCRYPTION_KEY` (`App\Casts\EncryptedToken`), not `APP_KEY`. They never reach Livewire state or views.
- `social_accounts` is unique on `(provider, external_account_id)` per agency. Connecting an account another client owns is refused with a message, and re-linking for the same client is a reconnect.

## Health

| Schedule | Command | Effect |
|---|---|---|
| hourly | `tokens:refresh-due` | Queues `RefreshSocialToken` for tokens expiring within 72 h (`sync` queue). Threads refreshes with the access token itself. |
| daily 03:15 | `accounts:health-check` | Queues `CheckSocialAccountHealth`: validates the grant, syncs granted scopes, flags `needs_reconnect` |
| daily | `oauth:prune-states` | Removes expired OAuth states and parked results |

An auth failure moves the account to `needs_reconnect`, writes a `social_account_events` row, and e-mails the person who connected it plus the agency owner/admins (once per transition). A removed scope sets `social_permissions.granted = false`, which downgrades capabilities immediately.

Run `php artisan schedule:work` locally (included in `composer dev`).

## Meta app callbacks

Register these in the Meta app (required for app review):

| Setting | URL |
|---|---|
| Valid OAuth redirect URIs | `https://{central}/oauth/facebook/callback`, `https://{central}/oauth/instagram/callback` |
| Deauthorize callback URL | `https://{central}/webhooks/meta/deauthorize` |
| Data deletion request URL | `https://{central}/webhooks/meta/data-deletion` |

Both callbacks verify `signed_request` with the app secret, are deduplicated in `inbound_webhooks`, and revoke the matching accounts inside their own tenant via `social_identity_links`. That table holds an HMAC of the provider user id; the raw id is never stored centrally.

## Going live with a provider

1. Create the developer app under the company account. Register the callback URL shown on **Admin → Integrations**.
2. Enter the client ID and secret on that page (the secret is write-only). Set the review status, and enable the integration once approved.
3. Re-verify endpoints, scopes and API versions in `config/social.php` and the capability JSON against current provider docs. Bump `version` in the JSON if behaviour changed, run `social:sync-capabilities`, then set the integration's capability version.
4. LinkedIn organisation pages: set `LINKEDIN_ORGANIZATION_ACCESS=true` only after the Community Management API is approved.

## Adding a provider

1. Add an adapter implementing `SocialProvider` (extend `AbstractOAuthProvider`) and normalise errors into `AuthError`, `PermissionError`, `RateLimited` and `TransientError`.
2. Register it in `config/social.php` (and an integration entry if it's a new app).
3. Add `config/social/capabilities/{key}.json`.
4. Add a dataset row to `tests/Feature/Social/ProviderAdaptersTest.php`.
