Google Health Integration — Next Steps

This document is the operator handover for the implementation that landed in this branch. Everything in code is built; what is left is configuration, external setup, and verification you can only do as the owner of the Google Cloud project and a real Google account with Google Health data.

If you want a refresher on what was built and why, read docs/google-health/project-plan.md (especially the "Corrections Applied During Implementation Review" section at the top).


TL;DR

  1. Create a Google Cloud project and enable the Google Health API at console.developers.google.com/apis/library/health.googleapis.com.
  2. Configure the OAuth consent screen (Privacy Policy + Terms of Service URLs are required for the googlehealth.* sensitive scopes).
  3. Create a Web application OAuth client with the redirect URI: https://your-domain/health_connections/google_callback.
  4. Add credentials to Rails:
    bin/rails credentials:edit
    
    google_health:
      client_id: "xxxxx.apps.googleusercontent.com"
      client_secret: "GOCSPX-xxxxx"
      redirect_uri: "http://localhost:3004/health_connections/google_callback"
      project_id: "your-gcp-project-id"        # only required for webhooks
      webhook_secret: "a-long-random-string"   # only required for webhooks
      webhook_endpoint_url: "https://your-public-tunnel/webhooks/google_health"  # optional override
    
  5. Enable Active Record encryption (see "Token Encryption" below).
  6. Visit /health, click Connect Google Health, complete OAuth.
  7. Open Mission Control Jobs (/jobs) and watch Health::BackfillJob + Health::SyncJob run.
  8. Visit /health_sport_mappings and /health_habit_rules to configure how imported exercise sessions become activity logs and which habits auto-complete from synced data.

1. Google Cloud Project Setup

Follow the official Google Health API setup guide: https://developers.google.com/health/setup

  1. Open https://console.cloud.google.com and create a project, e.g. Lifehub Health.
  2. APIs & Services → Library → enable Google Health API (direct link: console.developers.google.com/apis/library/health.googleapis.com).
  3. APIs & Services → OAuth consent screen / Branding:
    • User type: External (or Internal for a Google Workspace).
    • App name: Lifehub.
    • User support email and developer email: yours.
    • Authorized domains: your production hostname (e.g. lifehub.app).
    • Privacy Policy URL and Terms of Service URL — required by Google before they will release the googlehealth.* scopes to real users.
  4. Audience (console.developers.google.com/auth/audience) — add your Google account as a test user while the app is in testing mode (Google caps testing-mode clients at 100 test users).
  5. Data Access / Scopes (console.developers.google.com/auth/scopes): search for "Google Health API" and add the readonly scopes Lifehub requests (see §3.2 below).
  6. Credentials → Create credentials → OAuth client ID → Web application:
    • Name: Lifehub Web.
    • Authorized redirect URIs:
      • http://localhost:3004/health_connections/google_callback
      • http://127.0.0.1:3000/health_connections/google_callback
      • https://your-production-domain/health_connections/google_callback
    • Save the Client ID and Client Secret.

⚠️ Google's setup tutorial uses https://www.google.com as the redirect URI because it's walking developers through the manual OAuth Playground flow. We do not use that — Lifehub uses the standard Web Application flow and our own callback URL.


2. Rails Configuration

2.1 Credentials

bin/rails credentials:edit

Add:

google_health:
  client_id: "xxxxx.apps.googleusercontent.com"
  client_secret: "GOCSPX-xxxxx"
  redirect_uri: "http://localhost:3004/health_connections/google_callback"

  # The next three are only needed once you enable webhooks (§4):
  project_id: "your-gcp-project-id"
  webhook_secret: "a-long-random-string"
  webhook_endpoint_url: "https://applifehub.com/webhooks/google_health"

Where each value comes from:

  • client_id / client_secret — from the OAuth client you created in §1 step 6. They appear once on the Credentials page after you click "Create"; you can also re-download them later from https://console.cloud.google.com/apis/credentials.
  • redirect_uri — must match a URI you registered in §1 step 6, byte for byte. If you omit it, the app falls back to Configuration.default_host + /health_connections/google_callback (http://localhost:3004 in development, https://applifehub.com in production). Set it explicitly when running on a non-default port.
  • project_id — the Project ID of your Google Cloud project (a slug like lifehub-health-431207, NOT the display name). Find it in the Cloud Console project picker (shown as ID: beneath the name) or at https://console.cloud.google.com/iam-admin/settings. Setting this also adds the cloud-platform scope to the OAuth consent request, so all existing users must reconnect (see §4.1).
  • webhook_secret — you generate this yourself. Run:
    bin/rails runner 'puts SecureRandom.hex(32)'
    

    Paste the output into credentials. Lifehub passes it to Google as endpointAuthorization.secret = "Bearer <your-secret>", and Google forwards that same string in the Authorization header on every push.

  • webhook_endpoint_url — the publicly reachable HTTPS URL where Google posts notifications. Production: https://applifehub.com/webhooks/google_health. Local dev: a tunnel URL like https://abc-123.trycloudflare.com/webhooks/google_health or https://abc-123.ngrok.io/webhooks/google_health. The localhost fallback won't work — Google can't reach your laptop without a tunnel.

2.2 OAuth scopes used by Lifehub

These are baked into Health::Google::Configuration::SCOPES and must match the scopes you add to the OAuth consent screen:

https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly
https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly
https://www.googleapis.com/auth/googlehealth.sleep.readonly
https://www.googleapis.com/auth/googlehealth.profile.readonly
openid
email

Lifehub does not request write or location scopes in v1.

2.3 Feature gate

The "Connect Google Health" button is hidden when credentials are missing. You can additionally gate it on:

config.x.google_health.enabled = true

(checked via Health::Google::Configuration.enabled?).

2.4 Token encryption (REQUIRED before production)

Active Record encryption was not configured in this app before this feature. Generate keys once:

bin/rails db:encryption:init

Copy the three keys into bin/rails credentials:edit:

active_record_encryption:
  primary_key: "<from db:encryption:init>"
  deterministic_key: "<from db:encryption:init>"
  key_derivation_salt: "<from db:encryption:init>"

Then enable encryption on the token columns in app/models/health/connection.rb. Lifehub ships with support_unencrypted_data: true on both encrypted attributes so existing plaintext rows remain readable during the migration window:

class Health::Connection < ApplicationRecord
  encrypts :access_token,  support_unencrypted_data: true
  encrypts :refresh_token, support_unencrypted_data: true
end

Migration playbook

If you turn encryption on with live connections already in the database, follow this order:

  1. Deploy with support_unencrypted_data: true (the default in this branch). Plaintext reads stop failing immediately.
  2. Re-save all connections so existing tokens get encrypted. Either:
    • Easiest — disconnect via the UI and reconnect once. The OAuth callback writes fresh tokens through the encrypted accessor.
    • Or bulk-encrypt in a Rails console:
      Health::Connection.find_each do |c|
        c.update!(access_token: c.access_token, refresh_token: c.refresh_token)
      end
      

      Assigning the same value marks the attribute dirty, which triggers encryption on save.

  3. Once every row has been touched, tighten security by changing both support_unencrypted_data: true lines to drop the option:
    encrypts :access_token
    encrypts :refresh_token
    

    From that point, any unencrypted bytes in those columns will raise ActiveRecord::Encryption::Errors::Decryption again.

Decryption-safety net

The codebase has defensive rescues so a misconfigured encryption (lost key, key rotation midway, partially-migrated row) never produces a 500:

  • HealthConnectionsController#destroy reads the refresh token via connection.safe_refresh_token (returns nil on decryption failure) and silently skips the Google revoke step, so disconnect always succeeds locally.
  • Health::SyncRunner catches decryption errors during sync and flips the connection to :revoked with a "Please reconnect" message. The dashboard then redirects to /health/connect.

3. Verify the OAuth Flow Locally

  1. Run bin/dev — this starts the server on port 3004 and the Solid Queue worker as a Procfile process (you don't need a separate bin/jobs).
  2. Visit http://localhost:3004/health.
  3. Click Connect Google Health. You'll be redirected to Google.
  4. Approve the scopes with your test-user Google account.
  5. Google redirects back to /health_connections/google_callback?code=….
  6. The controller exchanges the code, stores tokens, optionally registers the webhook subscriber (if project_id + webhook_endpoint_url are set), then kicks off Health::BackfillJob.perform_later.
  7. Watch /jobs for Health::BackfillJob and follow-up Health::SyncJob.
  8. After ~30 seconds you should see exercise sessions and daily metrics on /health.

Common gotchas:

  • redirect_uri_mismatch — the URI in Rails credentials must match a URI registered in the Google Cloud OAuth client byte-for-byte (including the port).
  • access_denied — your account is not in the Google Cloud test users list, or the consent screen was cancelled.
  • invalid_grant on first refresh — Google did not return a refresh token (only returned on first consent or when prompt=consent is set). The code already sends prompt=consent; if this triggers, disconnect and reconnect.
  • Testing-mode refresh tokens expire after 7 days. Production tokens last roughly six months. Promote the OAuth client out of testing mode before depending on long-lived connections.

Google Health API supports first-class subscribers — Google pushes data change notifications to your endpoint so you do not have to poll. The implementation is gated on credentials so you can ship without webhooks and turn them on later.

4.1 Prerequisites

Health::Google::Configuration.webhooks_configured? returns true only when all four of these are set, and only then will the connect flow attempt to register a subscriber:

  • project_id — so Lifehub knows the GCP project that owns the subscriber.
  • webhook_secret — the value Google forwards verbatim in the Authorization header (prefixed with Bearer by Lifehub).
  • webhook_endpoint_url — must start with https://. In development use a Cloudflare Tunnel or ngrok URL and set it explicitly; the localhost fallback won't be reachable from Google.
  • client_id + client_secret (required for the connection itself).

⚠️ Additional OAuth scope. Subscriber creation requires the https://www.googleapis.com/auth/cloud-platform scope. Lifehub adds this automatically when project_id is set — but every existing connection must reconnect after you add project_id, because the access token they have won't include the new scope.

⚠️ Supported data types are limited. Google only allows webhook subscribers for: altitude, distance, floors, sleep, steps, weight. Exercise and heart-rate are NOT subscribable today — those still rely on the polled Health::SyncJob (every 2 hours).

4.2 Verification handshake

Before projects.subscribers.create succeeds, Google POSTs to your webhook_endpoint_url twice:

  1. With Authorization: Bearer <webhook_secret> and body {"type":"verification"}. Lifehub's Webhooks::GoogleHealthController returns HTTP 201 Created (Google checks for 201 specifically).
  2. Without the Authorization header. Lifehub returns HTTP 401 Unauthorized.

Both checks must pass or the subscriber isn't created. If you see a generic "could not create subscriber" failure, check that the public webhook URL is reachable from Google's IPs and that no proxy/CDN is rewriting the Authorization header.

4.3 Behavior

When webhooks_configured? is true:

  • On successful OAuth connect, the controller calls Health::Google::SubscriberManager.register(connection) which POSTs to /v4/{parent=projects/*}/subscribers with the data type filters listed in Health::Google::SubscriberManager::DEFAULT_DATA_TYPES.
  • Google stores the subscriber and returns a name like projects/{project}/subscribers/{subscriber}, which Lifehub persists in health_connections.webhook_subscriber_name.
  • On disconnect, SubscriberManager.unregister DELETEs that subscriber.
  • Incoming webhook calls (POST /webhooks/google_health) are verified with the shared secret, then enqueue Health::WebhookSyncJob for the matching connection.

If webhooks_configured? is false, the system falls back to polling via the health_sync_connected_users recurring job (every 2 hours).


5. Configure Imports

After the first sync, two screens become useful:

  1. /health_sport_mappings — every distinct Google Health exercise type you imported has a row. Pick the Lifehub sport it should map to, decide whether unmapped types still create activity logs, and decide whether Lifehub should auto-create a sport.
  2. /health_habit_rules — pick a habit, a rule type, and a threshold:
    • exercise_session → completes when any exercise of a given type happens that day (e.g. running).
    • sport_activity → completes when an imported activity logs against a specific Lifehub sport.
    • daily_metric_threshold → completes when a metric (steps, distance, calories, etc.) hits a number for the day.
    • duration_threshold → completes when total minutes of a specific exercise type for the day reach a number.

Habits are evaluated at the end of each sync run via Health::HabitRuleEvaluator, which calls Habit#complete_on! so streaks and goals refresh normally.


6. XP Policy for Imports

For v1, imports do not award XP. Reasoning:

  • Backfilling historical data would mint dozens of XP transactions even for a 14-day window.
  • The existing ActivityLogsController#create and HabitsController#toggle XP paths are not idempotent against provider replays.
  • Google Health can correct/update sessions after the fact.

If you later want to award XP for imports, gate it on a unique source key (e.g. google_health:<activity_log_id> and google_health:<habit_id>:<date>:<rule_id>) so replays are no-ops.


7. Scheduled Sync

config/recurring.yml already has:

health_sync_connected_users:
  class: Health::SyncConnectedUsersJob
  schedule: every 2 hours

Restart the worker after deploys (bin/jobs). To trigger manually:

Health::SyncConnectedUsersJob.perform_later

If you turn webhooks on (§4), you can safely lengthen the schedule (or keep it as a safety net).


7.1 OAuth Verification — "In Production" ≠ "Verified Scopes"

This is the single most confusing part of Google's OAuth model, and worth understanding before you depend on the integration for real users.

The two-axis model

Google has two independent flags on every OAuth client:

  1. Publishing statusTesting or In production. Lifehub's OAuth client (the same one already used for Google login) is in In production, so anyone with a Google account can complete the OAuth flow without being on a test-user list.
  2. Per-scope verification — every sensitive or restricted scope is individually Approved, In review, or Unapproved. Adding new googlehealth.* scopes does not carry over your existing login approval; those scopes start Unapproved.

"In production" only means the app is publicly discoverable. It does NOT mean Google has verified the scopes you request.

What you get before verification, with an "In production" client

When you add the googlehealth.* scopes to a production-published client but haven't submitted them for verification yet:

  Behavior
Who can connect Anyone with a Google account ✅
Test-user list required No ✅
Refresh token lifetime ~6 months ✅
Consent screen UX Shows "Google hasn't verified this app" + user must click Advanced → Go to applifehub.com (unsafe) ⚠️
OAuth user cap 100 lifetime users who grant any unapproved scope — Google then blocks further grants permanently ⚠️
Cap reset Never — the 100 cap is for the project's lifetime ⚠️

The Audience page in the Cloud Console shows your current usage as N/100. The counter increments every time a new user grants an unapproved scope.

How to confirm scope status

In the Cloud Console:

  1. Google Auth Platform → Verification Center — lists every scope you request along with Approved, In review, or Needs verification.
  2. Google Auth Platform → Data Access — the page where you add scopes to the consent screen. Scopes you add but don't submit will show as unapproved in the Verification Center.

Save the scopes from §2.2 on the Data Access page, then check the Verification Center to see exactly which googlehealth.* scopes are unapproved (likely all of them on first add).

  1. Build and ship today. With your client already in production, you can connect from applifehub.com, dogfood the integration, and let a handful of real users try it. Just expect the unverified warning and the burn-down clock on the 100-user cap.
  2. Get Privacy Policy + ToS pages live on applifehub.com that specifically describe health-data handling. Verification cannot start without them.
  3. Record a YouTube demo video showing the OAuth consent screen, what data Lifehub requests, and how each piece is displayed/used. Google requires this for sensitive/restricted scopes. Record it early — it's the most common reason verification stalls.
  4. Submit for verification via the Verification Center when stable. Sensitive scopes are usually a 1–3 week turnaround. Restricted scopes (which may include googlehealth.health_metrics_and_measurements.readonly) add a CASA security assessment requirement — third-party audit, weeks of work, and meaningful cost. Confirm the classification in the Verification Center before committing to a timeline.

What changes after verification

  Behavior
Consent screen UX No warning — just the standard "Lifehub wants to access…" page ✅
OAuth user cap Lifted entirely for the approved scopes ✅
Brand display Your verified app name + logo show on the consent screen ✅

If verification is rejected, Google sends an email with the specific gap (usually missing/inadequate privacy policy, video, or scope justification). Address and resubmit — no penalty.


8. Production Rollout Checklist

Before opening this beyond yourself:

  • Privacy Policy and Terms of Service URLs on the OAuth consent screen mention health-data usage.
  • Google OAuth verification submitted (the googlehealth.* scopes are sensitive and require review).
  • Production redirect URI registered in Google Cloud.
  • Production google_health credentials in encrypted credentials.
  • active_record_encryption keys in encrypted credentials.
  • encrypts :access_token and encrypts :refresh_token enabled on Health::Connection.
  • Disconnect (DELETE /health_connections/:id) tested — unregisters the subscriber, revokes via https://oauth2.googleapis.com/revoke, flips status to disconnected.
  • bin/ci passes.
  • Brakeman + bundler-audit clean.

9. Manual Verification Plan

  1. Connect a Google account that has Google Health data (Fitbit, Pixel Watch, or a synced third-party integration).
  2. Sync — Mission Control shows Health::BackfillJob and follow-up Health::SyncJob running without error.
  3. /health shows recent sessions, recent imported activity logs, and latest metrics.
  4. Sport mappings — map at least one provider exercise type to a Lifehub sport, run Sync now, confirm the activity log links to the right sport.
  5. Habit rules — create a rule (e.g. "running → complete 'Run today'"), import a running session, confirm the habit toggles on its date and streaks bump.
  6. Disconnect — confirm last_synced_at stops moving, no new activity logs appear, and (if webhooks were on) the subscriber is removed in the Google Health API console.
  7. Reconnect — confirm existing imported activity logs are not duplicated. Idempotency comes from the unique [user_id, source_provider, source_external_id] index.

10. Known Gaps and Follow-ups

Items I left out on purpose. Ranked by likelihood you'll want them:

  1. Active Record encryption is not enabled by default — must run bin/rails db:encryption:init and add encrypts to Health::Connection before going live (see §2.4).
  2. No XP for imports — see §6.
  3. No alternative providers — only Google Health. The schema is provider-shaped (Health::Connection#provider enum), so adding a second provider later is a model + importer change without touching the rest.
  4. No granular intraday persistenceHealth::Google::DailySummaryImporter pulls a hard-coded list of ROLLUP_KEYS (steps, distance, etc. via :dailyRollUp) plus every daily-* data type in the registry (daily-resting-heart-rate, daily-heart-rate-variability, daily-oxygen-saturation, etc.). Raw heart-rate, oxygen-saturation, and similar samples are not stored. To capture them, call Health::Google::Client#list_data_points(data_type: "heart-rate", ...) directly and persist as Health::DataPoint with record_kind: :sample.
  5. No soft-delete reconciliation — Google reporting "this data point was deleted" is not yet wired up. The Health::*#deleted_at columns exist; add the call in Health::Google::ExerciseImporter#call.
  6. DataPoint value extraction is best-effort. Health::Google::DailySummaryImporter#extract_value walks a small list of numeric field names. For data types that nest their value differently (e.g. heart-rate zone breakdowns), extend the NUMERIC_KEYS list or add a per-type extractor.
  7. No I18n strings — every label has a default: fallback so the UI renders in English. Add health: keys to config/locales/en.yml and pt-BR.yml before shipping.
  8. Pre-existing MCP test pollution — unrelated to this branch, but bin/rails test shows ordering-dependent failures in test/integration/mcp/tools/. They pass in isolation.

11. Quick Reference: API Surface

What URL
API base https://health.googleapis.com/v4
Discovery doc https://health.googleapis.com/$discovery/rest?version=v4
OAuth authorization https://accounts.google.com/o/oauth2/v2/auth
OAuth token exchange https://oauth2.googleapis.com/token
OAuth revoke https://oauth2.googleapis.com/revoke
User identity GET /v4/users/me/identity
Data points list GET /v4/users/me/dataTypes/{dataType}/dataPoints
Daily rollup POST /v4/users/me/dataTypes/{dataType}/dataPoints:dailyRollUp
Physical-time rollup POST /v4/users/me/dataTypes/{dataType}/dataPoints:rollUp
Create subscriber POST /v4/projects/{project}/subscribers
Delete subscriber DELETE /v4/projects/{project}/subscribers/{name}

Supported data type slugs live in app/models/health/google/data_type_registry.rb and include exercise, sleep, steps, distance, floors, active-minutes, active-zone-minutes, heart-rate, heart-rate-variability, weight, body-fat, daily-resting-heart-rate, daily-heart-rate-variability, daily-vo2-max, and more.


12. Files Added / Modified

Added:

app/controllers/health_connections_controller.rb
app/controllers/health_controller.rb
app/controllers/health_habit_rules_controller.rb
app/controllers/health_sport_mappings_controller.rb
app/controllers/webhooks/google_health_controller.rb
app/jobs/health/backfill_job.rb
app/jobs/health/sync_connected_users_job.rb
app/jobs/health/sync_job.rb
app/jobs/health/webhook_sync_job.rb
app/models/health.rb
app/models/health/activity_log_projector.rb
app/models/health/connection.rb
app/models/health/data_point.rb
app/models/health/exercise_session.rb
app/models/health/google.rb
app/models/health/google/client.rb
app/models/health/google/configuration.rb
app/models/health/google/daily_summary_importer.rb
app/models/health/google/data_type_registry.rb
app/models/health/google/exercise_importer.rb
app/models/health/google/oauth_client.rb
app/models/health/google/subscriber_manager.rb
app/models/health/google/token_refresher.rb
app/models/health/habit_rule.rb
app/models/health/habit_rule_evaluator.rb
app/models/health/sport_mapping.rb
app/models/health/sync_runner.rb
app/policies/health/connection_policy.rb
app/policies/health/habit_rule_policy.rb
app/policies/health/sport_mapping_policy.rb
app/policies/health_policy.rb
app/views/health/show.html.erb
app/views/health_habit_rules/index.html.erb
app/views/health_sport_mappings/index.html.erb
db/migrate/20260520170801_create_health_connections.rb
db/migrate/20260520170802_create_health_data_points.rb
db/migrate/20260520170803_create_health_exercise_sessions.rb
db/migrate/20260520170804_create_health_sport_mappings.rb
db/migrate/20260520170805_create_health_habit_rules.rb
db/migrate/20260520170806_extend_activity_logs_for_health_imports.rb
db/migrate/20260520170807_add_subscriber_name_to_health_connections.rb
test/controllers/health_controller_test.rb
test/fixtures/health/connections.yml
test/fixtures/health/data_points.yml
test/fixtures/health/exercise_sessions.yml
test/fixtures/health/habit_rules.yml
test/fixtures/health/sport_mappings.yml
test/models/health/activity_log_projector_test.rb
test/models/health/connection_test.rb
test/models/health/habit_rule_evaluator_test.rb

Modified:

AGENTS.md                                       (allowed gems: googleauth)
Gemfile / Gemfile.lock                          (gem 'googleauth')
app/helpers/application_helper.rb               (nav icon: "health")
app/models/activity_log.rb                      (source enum, optional sport, imported scope)
app/models/user.rb                              (has_many :health_*)
app/views/shared/_sidebar_links.html.erb        (Health nav entry)
config/recurring.yml                            (health_sync_connected_users)
config/routes.rb                                (health resources + webhook)
docs/google-health/implementation-plan.md       (gem removed, direct REST)
docs/google-health/project-plan.md              ("Corrections Applied" header)