# Phase 16: Federation — Instance-to-Instance Campaign Network ## Context Changemaker Lite instances are currently isolated islands. This feature introduces a **federated discovery network** where any instance can act as a **hub** (accepting registrations, serving a directory) and/or a **spoke** (registering with hubs, sharing campaigns). The goal is organic, admin-to-admin networking with public campaign discoverability as a secondary benefit. **Design principles:** - Any instance can be a hub, spoke, or both — no central authority - Medium-depth campaign sharing: enough metadata for discovery, click-through to source - Per-campaign federation toggle — admins choose what's shared - Strict privacy boundary: **never** share emails, participant data, queue data, addresses, volunteer/canvass data, or credentials - Hub admins curate their own directories — organic > control --- ## Prisma Schema Changes **File:** `api/prisma/schema.prisma` ### New enums ``` FederationPeerStatus: PENDING | ACTIVE | REJECTED | SUSPENDED | OFFLINE FederationRole: HUB | SPOKE ``` ### New models **FederationIdentity** (singleton — this instance's federation profile) - `enabled`, `hubEnabled`, `hubAutoApprove` - Instance profile: `instanceName`, `instanceDescription`, `instanceUrl`, `instanceRegion`, `instanceTags` (Json), `instanceLogoUrl` - Ed25519 keypair: `publicKey`, `privateKey` (encrypted at rest) - Hub description, sync interval, last sync timestamp/error **FederationPeer** (one record per connection, in either direction) - `role` (HUB or SPOKE), `remoteUrl` (unique per role+url) - Remote instance profile fields (name, description, region, tags, logo, publicKey) - Auth: `apiKey` (ours for them), `remoteApiKey` (theirs for us) — both encrypted - Status tracking: `status`, `statusMessage`, `lastSeenAt`, `lastSyncAt`, `failureCount` - Stats: `campaignsShared`, `responsesShared` - Relation to `FederatedCampaign[]` **FederatedCampaign** (cached campaign metadata from peers) - `peerId` → FederationPeer - Remote identifiers: `remoteCampaignId`, `remoteCampaignSlug` - Safe metadata: title, description, emailSubject (NOT body), callToAction, coverPhoto, status, targetGovernmentLevels, featureFlags (Json), createdByName - Aggregate stats: `emailCount`, `responseCount` - Source instance info (denormalized): `sourceInstanceName`, `sourceInstanceUrl`, `sourceInstanceRegion` - Staleness tracking: `lastSyncedAt`, `isStale` - Future adoption: `adoptedAsCampaignId` (nullable FK to local Campaign) - Unique constraint: `[peerId, remoteCampaignId]` ### Modifications to existing models **Campaign** — add `federated Boolean @default(false)` field **SiteSettings** — add `enableFederation Boolean @default(false)` feature toggle --- ## API Module Structure **New directory:** `api/src/modules/federation/` | File | Purpose | |------|---------| | `federation.schemas.ts` | Zod schemas: identity update, peer registration, campaign sync, directory query, list filters | | `federation.service.ts` | Core business logic: identity CRUD, peer management, `buildSafeCampaignPayload()`, campaign sync, directory serving | | `federation-admin.routes.ts` | SUPER_ADMIN routes: identity management, peer approve/reject/suspend, manual sync trigger | | `federation-peer.routes.ts` | Inter-instance routes: inbound registration, campaign sync, directory, heartbeat (API-key auth) | | `federation-public.routes.ts` | Public browsing: federated campaigns list, instance directory (no auth) | | `federation-crypto.service.ts` | Ed25519 keypair generation, request signing/verification | **New file:** `api/src/services/federation-sync-queue.service.ts` — BullMQ repeatable job for periodic sync ### Route table **Admin routes** (`/api/federation/...`, SUPER_ADMIN + JWT auth): | Method | Path | Description | |--------|------|-------------| | GET | `/identity` | Get federation config | | PUT | `/identity` | Update config/profile | | POST | `/identity/generate-keypair` | Generate Ed25519 keypair | | GET | `/peers` | List all peers | | POST | `/peers/register` | Register with a remote hub | | POST | `/peers/:id/approve` | Approve incoming spoke | | POST | `/peers/:id/reject` | Reject incoming spoke | | POST | `/peers/:id/suspend` | Suspend peer | | DELETE | `/peers/:id` | Remove peer | | POST | `/sync` | Trigger manual sync | | GET | `/sync/status` | Sync status + history | **Peer routes** (`/api/federation/peer/...`, API-key auth via `X-Federation-Key` header): | Method | Path | Description | |--------|------|-------------| | POST | `/register` | Inbound spoke registration | | POST | `/sync` | Inbound campaign metadata push | | GET | `/directory` | Serve campaign directory | | GET | `/profile` | Return instance profile | | POST | `/heartbeat` | Liveness check | **Public routes** (`/api/federation/...`, no auth): | Method | Path | Description | |--------|------|-------------| | GET | `/campaigns` | Browse federated campaigns (paginated, searchable) | | GET | `/campaigns/:id` | Single federated campaign detail | | GET | `/instances` | List known network instances | ### Mounting in server.ts ``` app.use('/api/federation', federationPublicRouter); // No auth — first app.use('/api/federation', federationPeerRouter); // API-key auth app.use('/api/federation', federationAdminRouter); // SUPER_ADMIN JWT ``` --- ## Federation Protocol ### Registration handshake 1. Spoke admin enters hub URL, clicks "Register" 2. Spoke sends `POST /api/federation/peer/register` to hub with instance profile + generated API key 3. Hub creates peer record (PENDING or ACTIVE if `hubAutoApprove`) 4. Hub responds with its own API key + peer ID 5. If approved (now or later), hub calls back to spoke's `/peer/register` to complete mutual registration 6. Both instances now have each other as peers (Spoke→HUB role, Hub→SPOKE role) ### Campaign sync - Spokes push federated campaigns to hubs on schedule (BullMQ repeatable job) - Payload: array of safe campaign metadata + array of un-federated campaign IDs (for removal) - Hub stores/updates `FederatedCampaign` records - Sync includes heartbeat (updates `lastSeenAt`) ### Privacy boundary enforcement `buildSafeCampaignPayload()` in the service layer filters campaigns to only safe fields. **Never included:** emailBody, any email addresses, user IDs, participant data, moderation internals, custom recipients, calls data. ### Offline handling - Increment `failureCount` on sync failure; after 5 consecutive failures → status `OFFLINE` - Mark federated campaigns as `isStale` after 24h offline - Keep checking with exponential backoff (max 24h) - Auto-recover when heartbeat succeeds --- ## Security - **API-key auth:** `crypto.randomBytes(32).toString('hex')`, encrypted at rest with existing `encrypt()`/`decrypt()` utility - **Custom middleware:** `authenticatePeer` checks `X-Federation-Key` header, verifies peer exists + is ACTIVE - **Request signing (optional):** Ed25519 signatures on `X-Federation-Signature` header for non-repudiation (configurable, not enforced in MVP) - **Rate limiting:** 30 req/min for peer routes, 60 req/min for public routes (separate Redis prefixes) - **CORS:** Peer routes need permissive CORS (cross-domain by nature) - **Input validation:** All incoming peer data Zod-validated + HTML-escaped before storage --- ## Environment Variables Add to `api/src/config/env.ts`: ``` ENABLE_FEDERATION: z.string().default('false') FEDERATION_SYNC_INTERVAL_MINUTES: z.coerce.number().default(60) FEDERATION_MAX_CAMPAIGNS_PER_SYNC: z.coerce.number().default(500) FEDERATION_PEER_TIMEOUT_MS: z.coerce.number().default(15000) FEDERATION_MAX_PEERS: z.coerce.number().default(50) ``` --- ## Admin UI ### FederationPage (`admin/src/pages/FederationPage.tsx`) 4-tab page following PangolinPage pattern: **Tab 1 — Identity & Settings:** Toggle federation, instance profile form, keypair management, hub/spoke settings **Tab 2 — Connected Peers:** Table of peers (name, URL, role tag, status tag, campaigns shared, last sync, actions). "Register with Hub" button opens modal. Pending incoming registrations highlighted. **Tab 3 — Federated Campaigns:** Card grid/table of federated campaigns with search + filter (region, tags, government level). Click-through links to source instances. **Tab 4 — Sync Status:** Last/next sync, per-peer status, manual sync button, sync history. ### Sidebar Add to `buildMenuItems()` in `AppLayout.tsx`, gated on `settings?.enableFederation`: ```typescript { key: '/app/federation', icon: , label: 'Federation' } ``` (Using `` since `` is already imported but used for Web submenu — may use `` or `` instead) ### Route in App.tsx ```tsx } /> ``` ### Campaign form integration Add `federated` checkbox to campaign create/edit form in CampaignsPage, visible only when `settings.enableFederation` is true. ### TypeScript types Add `FederationIdentity`, `FederationPeer`, `FederatedCampaign`, `FederationSyncStatus` interfaces to `admin/src/types/api.ts`. ### Public network page (stretch goal in MVP) `admin/src/pages/public/FederatedCampaignsPage.tsx` at `/network` route — card grid of federated campaigns with PublicLayout dark theme. --- ## Prometheus Metrics Add to `api/src/utils/metrics.ts`: - `cm_federation_peers_active` (Gauge) - `cm_federation_campaigns_shared` (Gauge) - `cm_federation_sync_duration_seconds` (Histogram) - `cm_federation_sync_errors_total` (Counter with `peer_id` label) --- ## Implementation Order | Step | Description | Files Created/Modified | Depends On | |------|-------------|----------------------|------------| | 1 | **Prisma schema** — Add enums, 3 new models, Campaign.federated, SiteSettings.enableFederation | `api/prisma/schema.prisma` | — | | 2 | **Migration** — `npx prisma migrate dev --name add-federation` | `api/prisma/migrations/` | Step 1 | | 3 | **Env vars** — Add federation config to env.ts + .env.example | `api/src/config/env.ts`, `.env.example` | — | | 4 | **Crypto service** — Ed25519 keypair, sign/verify | `api/src/modules/federation/federation-crypto.service.ts` | — | | 5 | **Schemas** — Zod validation for all federation endpoints | `api/src/modules/federation/federation.schemas.ts` | Step 1 | | 6 | **Core service** — Identity CRUD, peer management, buildSafeCampaignPayload, campaign sync logic | `api/src/modules/federation/federation.service.ts` | Steps 2, 4, 5 | | 7 | **Admin routes** — SUPER_ADMIN federation management | `api/src/modules/federation/federation-admin.routes.ts` | Step 6 | | 8 | **Peer routes** — Inter-instance API with authenticatePeer middleware | `api/src/modules/federation/federation-peer.routes.ts` | Step 6 | | 9 | **Public routes** — Browsing federated campaigns | `api/src/modules/federation/federation-public.routes.ts` | Step 6 | | 10 | **Rate limiting** — Add federation rate limiters | `api/src/middleware/rate-limit.ts` | — | | 11 | **Server mounting** — Import + mount routers, start sync queue | `api/src/server.ts` | Steps 7-10 | | 12 | **Sync queue** — BullMQ repeatable job for periodic sync | `api/src/services/federation-sync-queue.service.ts` | Step 6 | | 13 | **Metrics** — Prometheus counters/gauges | `api/src/utils/metrics.ts` | — | | 14 | **Campaign form** — Add `federated` to schemas + service + CampaignsPage checkbox | `api/src/modules/influence/campaigns/campaigns.schemas.ts`, `campaigns.service.ts`, `admin/src/pages/CampaignsPage.tsx` | Step 2 | | 15 | **Frontend types** — Federation TypeScript interfaces | `admin/src/types/api.ts` | — | | 16 | **FederationPage** — 4-tab admin page | `admin/src/pages/FederationPage.tsx` | Steps 7, 15 | | 17 | **Sidebar + routing** — Menu item + route in AppLayout/App.tsx | `admin/src/components/AppLayout.tsx`, `admin/src/App.tsx` | Step 16 | | 18 | **Public network page** (stretch) — Federated campaigns browse | `admin/src/pages/public/FederatedCampaignsPage.tsx` | Steps 9, 15 | --- ## Future Extensions (not in MVP, but models accommodate) - **Campaign adoption** — "Fork" a federated campaign locally (`FederatedCampaign.adoptedAsCampaignId`) - **Cross-instance response sharing** — New `FederatedResponse` model synced alongside campaigns - **Named networks/coalitions** — `FederationNetwork` + `FederationNetworkMember` models for named alliances - **Hub-of-hubs discovery** — Hubs share known-hub lists for transitive discovery (gossip protocol) --- ## Verification 1. **Two-instance test:** Run two API instances on different ports, enable federation on both, register one with the other 2. **Campaign sync:** Create a federated campaign on spoke, verify it appears in hub's directory 3. **Privacy boundary:** Inspect sync payloads — verify no emails, user IDs, or email bodies leak 4. **Offline handling:** Stop one instance, verify the other marks it OFFLINE after 5 failed syncs, then recovers on restart 5. **Rate limiting:** Hit peer endpoints rapidly, verify 429 responses after threshold 6. **Feature gate:** Disable federation in settings, verify all routes return 403/hidden 7. **UI:** Verify sidebar item appears/hides with feature toggle, all 4 tabs functional