Connected Apps integration framework
Reusable backend foundation for linking third-party services (Strava, Chess.com, ...) to a user's habits. Shipped in Phase 1 of milestone v4.14 (Activity Nexus). No provider-specific logic lands here — Strava (Phase 2, webhook-driven) and Chess.com (Phase 3, daily poll) build on top of this spine.
Data model
Four models, all under rails_api/app/models/. Each inherits from PublicRecord (so it gets public_id for GraphQL/API exposure) and uses acts_as_paranoid (soft delete via deleted_at, never hard-deleted).
erDiagram
IntegrationProvider ||--o{ IntegrationConnection : "has many"
User ||--o{ IntegrationConnection : "has many"
IntegrationConnection ||--o{ HabitIntegrationMapping : "has many"
IntegrationConnection ||--o{ IntegrationActivity : "has many"
User ||--o{ HabitIntegrationMapping : "has many"
Goal ||--o{ HabitIntegrationMapping : "mapped by"
Goal |o--o{ IntegrationActivity : "optionally checks"
IntegrationProvider
Catalog entry for a connectable third-party service — rails_api/app/models/integration_provider.rb.
- Fields:
provider_slug(unique, e.g."strava","chess_com"),name,logo_url,description,auth_type(oauth/api_key/none— seeAUTH_TYPES). - Associations:
has_many :integration_connections, dependent: :destroy. - Seeded rows (
db/migrate/20260723210004_seed_integration_providers.rb): Strava (auth_type: "oauth") and Chess.com (auth_type: "none"— its adapter won't implementauthorize_url, since Chess.com's public API needs no OAuth handshake).
IntegrationConnection
A user's link to a provider — rails_api/app/models/integration_connection.rb.
- Fields:
user,integration_provider,access_token,refresh_token(both encrypted — see Token encryption below),token_expires_at,status(active/paused/error— seeSTATUSES),synced_at. - Associations:
belongs_to :user,belongs_to :integration_provider,has_many :habit_integration_mappings, dependent: :destroy,has_many :integration_activities, dependent: :destroy. - No uniqueness constraint on
(user, integration_provider)— reconnect/reuse semantics for an already-connected provider are explicitly deferred to Phase 2'sIntegrations::AuthorizeConnection, not decided here.
HabitIntegrationMapping
Links a provider's activity type to an Objectuve habit (Goal) — rails_api/app/models/habit_integration_mapping.rb.
- Fields:
user,integration_connection,goal,provider_activity_type(e.g."Strava.run"),confidence_score(0–1, nullable — set by the Phase 4 auto-mapping engine),auto_check_enabled(defaulttrue). - Associations:
belongs_to :user,belongs_to :integration_connection,belongs_to :goal.
IntegrationActivity
A single fetched activity from a connected provider, optionally auto-checked against a habit — rails_api/app/models/integration_activity.rb.
- Fields:
integration_connection,goal(optional),provider_activity_id,activity_type,duration_minutes,distance_km,timestamp,checked_at,idempotency_key. - Associations:
belongs_to :integration_connection,belongs_to :goal, optional: true. - Idempotency:
idempotency_keyis derived, not client-supplied.before_validation :generate_idempotency_keybuilds it as"#{integration_connection_id}:#{provider_activity_id}:#{timestamp.to_date}"and a uniqueness validation (backed by a unique DB index) rejects a duplicate — this is what stops the same provider activity from being fetched and checked-in twice on the same calendar date. The unique index is not scoped bydeleted_at, so a soft-deletedIntegrationActivity's key can't be reused; no interaction soft-deletes one yet, so this is a note for whichever Phase 4 flow first does, not a current bug.
The adapter pattern
rails_api/lib/integration_provider/adapter.rb defines the abstract contract every provider adapter implements (IntegrationProvider::Strava, IntegrationProvider::Chess, ... — landing in Phases 2/3):
class IntegrationProvider::Adapter
def authorize_url(state:) # builds the OAuth authorization URL
def exchange_code(code:) # exchanges an OAuth callback code for tokens
def fetch_recent_activity(connection:) # returns normalized recent activities
def verify_signature(request) # verifies an inbound webhook signature
endEvery method raises NotImplementedError in the base class; a stub-subclass spec (spec/lib/integration_provider/adapter_spec.rb) proves the contract fails until a provider overrides it. Providers whose auth_type is none (Chess.com) aren't expected to implement authorize_url; providers without webhooks aren't expected to implement verify_signature.
GraphQL read surface
One query, connectedApps (Types::ConnectedAppsType, registered in app/graphql/types/query_type.rb via Resolvers::IntegrationQueries#connected_apps, app/graphql/resolvers/integration_queries.rb):
{
connectedApps {
providers { publicId providerSlug name authType }
connections { publicId status integrationProvider { providerSlug } }
mappings { publicId providerActivityType }
}
}providers— the full provider catalog (all users see the same list).connections— the current user's ownIntegrationConnections only.mappings— the current user's ownHabitIntegrationMappings only.
Ownership is enforced the same way as every other authenticated query: require_auth! raises UNAUTHORIZED for an unauthenticated request (verified via Clerk SessionToken → current_user), and connections/mappings are scoped off current_user.integration_connections / current_user.habit_integration_mappings rather than a global lookup, so another user's records are never returned. Both scoped associations preload the associations their GraphQL types expose (.includes(:integration_provider), .includes(:integration_connection, :goal)) to avoid N+1s.
IntegrationConnectionType never exposes access_token or refresh_token — the type only declares public_id, integration_provider, status, synced_at, token_expires_at, and created_at. No mutations exist on this surface yet; write paths (OAuth callback, webhook ingestion) land with their respective provider phases.
One graphql-ruby quirk worth knowing if you extend this surface: any field whose GraphQL return type name ends in "Connection" (i.e. IntegrationConnectionType) trips graphql-ruby's default Relay-connection auto-pagination heuristic, even for a singular, non-list field. Both ConnectedAppsType#connections and HabitIntegrationMappingType#integration_connection set connection: false explicitly to opt out.
Token encryption (the A-ENCRYPT gate)
IntegrationConnection#access_token / #refresh_token are encrypted at the application layer via Rails' ActiveRecord::Encryption (encrypts :access_token, :refresh_token in integration_connection.rb), storing ciphertext in text columns. This was the hard prerequisite for the whole framework — proven here before Phase 2 stores any real Strava token.
- Key provisioning:
active_record_encryption.primary_key/deterministic_key/key_derivation_saltlive inrails_api/config/credentials.yml.enc, decrypted the normal Rails way (RAILS_MASTER_KEY/config/master.key) in development, staging, and production. - Test environment: CI's
rspecjob deliberately has noRAILS_MASTER_KEY/config/master.key(it doesn't get deploy secrets), so it can't resolve the real credentials-backed keys.rails_api/config/environments/test.rbsets static, non-secretconfig.active_record.encryption.*values scoped toRAILS_ENV=testonly — encryption is still fully live in test, just with keys CI can see. Dev/staging/production are unaffected and still read the real keys fromcredentials.yml.enc. - Logs: no extra log-filter configuration was needed. Rails 7+ auto-filters
encrypts-declared attributes from#inspect, andconfig/initializers/filter_parameter_logging.rb's existingtoken/_keysubstrings already coveredaccess_token/refresh_token/idempotency_keyfor parameter logging. - Proof:
spec/models/integration_connection_spec.rbhas a passing encrypted round-trip spec — write a token, read it back decrypted, and confirm the raw DB column holds ciphertext, not plaintext.
What's not in Phase 1
- No provider-specific adapters (
IntegrationProvider::Strava,IntegrationProvider::Chess) — Phases 2 and 3. - No OAuth callback flow, webhook ingestion, or polling job — Phases 2 and 3.
- No auto-mapping engine (
Integrations::MapIncomingActivity) that would populateHabitIntegrationMapping#confidence_score— Phase 4. - No GraphQL mutations on this surface (connect, disconnect, edit a mapping) — later phases.
- No Connected Apps UI — Phase 5, with its own Desi UI-SPEC.
- No
(user, integration_provider)uniqueness constraint onIntegrationConnection— left for Phase 2 to decide reuse-vs-recreate semantics on reconnect.
Source references
- Models:
rails_api/app/models/integration_provider.rb,integration_connection.rb,habit_integration_mapping.rb,integration_activity.rb - Migrations:
rails_api/db/migrate/20260723210000–20260723210004 - Adapter:
rails_api/lib/integration_provider/adapter.rb - GraphQL:
rails_api/app/graphql/resolvers/integration_queries.rb,rails_api/app/graphql/types/connected_apps_type.rb,integration_provider_type.rb,integration_connection_type.rb,habit_integration_mapping_type.rb - Encryption:
rails_api/app/models/integration_connection.rb,rails_api/config/environments/test.rb
Last updated: 2026-08-02 · Version: v4.14 (Phase 1) · PR #1746