changemaker.lite/FEDERATION_PLAN.md
2026-02-18 17:15:31 -07:00

13 KiB

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:

{ key: '/app/federation', icon: <GlobalOutlined />, label: 'Federation' }

(Using <GlobalOutlined /> since <GlobalOutlined /> is already imported but used for Web submenu — may use <ClusterOutlined /> or <DeploymentUnitOutlined /> instead)

Route in App.tsx

<Route path="federation" element={<ProtectedRoute requiredRoles={['SUPER_ADMIN']}><FederationPage /></ProtectedRoute>} />

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 Migrationnpx 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/coalitionsFederationNetwork + 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