Skip to content

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 — see AUTH_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 implement authorize_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 — see STATUSES), 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's Integrations::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 (default true).
  • 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_key is derived, not client-supplied. before_validation :generate_idempotency_key builds 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 by deleted_at, so a soft-deleted IntegrationActivity'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):

ruby
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
end

Every 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):

graphql
{
  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 own IntegrationConnections only.
  • mappings — the current user's own HabitIntegrationMappings only.

Ownership is enforced the same way as every other authenticated query: require_auth! raises UNAUTHORIZED for an unauthenticated request (verified via Clerk SessionTokencurrent_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_salt live in rails_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 rspec job deliberately has no RAILS_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.rb sets static, non-secret config.active_record.encryption.* values scoped to RAILS_ENV=test only — encryption is still fully live in test, just with keys CI can see. Dev/staging/production are unaffected and still read the real keys from credentials.yml.enc.
  • Logs: no extra log-filter configuration was needed. Rails 7+ auto-filters encrypts-declared attributes from #inspect, and config/initializers/filter_parameter_logging.rb's existing token/_key substrings already covered access_token/refresh_token/idempotency_key for parameter logging.
  • Proof: spec/models/integration_connection_spec.rb has 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 populate HabitIntegrationMapping#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 on IntegrationConnection — 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/2026072321000020260723210004
  • 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

Loading…