Skip to content

feat: make WHOOP the primary health source - #10

Merged
Aarekaz merged 35 commits into
masterfrom
feat/whoop-health-source
Aug 21, 2026
Merged

feat: make WHOOP the primary health source#10
Aarekaz merged 35 commits into
masterfrom
feat/whoop-health-source

Conversation

@Aarekaz

@Aarekaz Aarekaz commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • add WHOOP OAuth connection management with encrypted refresh-token rotation and lifecycle fencing
  • ingest cycles, recoveries, sleep, workouts, profile, and body measurements through durable queues, webhooks, reconciliation, retention, and D1
  • expose typed health reads, synchronization health, export projections, OpenAPI contracts, and operational documentation
  • preserve Apple Health history while establishing WHOOP as the ongoing wearable source

Verification

  • npm run test:whoop — 185 tests passed
  • npm test — 235 tests passed
  • npm run typecheck — passed
  • fresh local D1 migration suite — passed
  • independent whole-API review — approved

Rollout gates

This PR is intentionally a draft. Before production rollout:

  • rotate the previously exposed API bearer credential
  • configure WHOOP OAuth/webhook secrets, queues, cron, and production environment variables
  • apply remote D1 migrations and deploy
  • complete user-controlled WHOOP consent and validate backfill, webhook, reconciliation, and DLQ behavior
  • keep Apple ingestion active through the observation window

No live provider calls, OAuth consent, remote migration, deployment, or Apple cutover were performed by this branch.

Preview verification

  • API branch preview: https://feat-whoop-health-source-api.aarekaz.workers.dev
  • Authenticated WHOOP overview returned HTTP 200 with the expected not_connected contract after migration 0020_whoop.sql.
  • Cloudflare Workers build and GitHub CI pass. Production Worker traffic was not changed.

Aarekaz added 30 commits August 19, 2026 21:11
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 21, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
api 730b861 Commit Preview URL

Branch Preview URL
Aug 21 2026, 04:29 PM

return crypto.subtle.importKey(
"raw",
decodeWhoopTokenEncryptionKey(keyMaterial),
{ name: "AES-GCM" },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The importEncryptionKey function is called on every encrypt/decrypt operation, re-importing the key material each time. While Web Crypto handles this efficiently, you could cache the imported CryptoKey if performance becomes a concern in high-throughput scenarios. Not a blocker, just something to keep in mind for later optimization.

if (typeof value !== "string" || value.length > 64) return false;
if (resource === "cycles") return /^(?:[1-9][0-9]*)$/.test(value);
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant-time string comparison implementation here is sound. Just noting that the early left.length ^ right.length assignment means length differences are already factored in, and the padded loop ensures timing doesn't leak length info. Well done.

throw new Error("WHOOP activity ID must be a UUID");
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The input validation for cycle IDs and activity UUIDs before constructing paths is a good defense-in-depth measure against path traversal. The UUID regex validation using Zod here ensures malicious input like ../../secrets can't be injected into the path.

if (operation !== "token refresh") throw error;
if (error instanceof WhoopRequestError
&& error.status !== undefined
&& error.status >= 400

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding an explicit timeout to the fetch calls. Cloudflare Workers have a default timeout, but a hung upstream WHOOP request could tie up worker resources. Something like AbortSignal.timeout(30_000) would provide defense against slow upstream responses.

Comment thread migrations/0020_whoop.sql
raw_json TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_whoop_recoveries_user_updated ON whoop_recoveries(whoop_user_id, upstream_updated_at);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nap column stores a boolean as INTEGER (SQLite convention), which is fine. Just noting the sleep stage millisecond fields use INTEGER while some other numeric fields use REAL - this is intentional since durations are whole milliseconds from the provider. Consistent approach.

Comment thread src/schemas/whoop.ts
weight_kilogram: z.number(),
max_heart_rate: z.number().int(),
}).passthrough();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using .passthrough() on provider schemas is the right call here - it ensures new fields WHOOP adds won't cause validation failures and will be preserved in raw_json. Good forward compatibility.

Comment thread docs/APPLE_SHORTCUTS.md
@@ -20,7 +20,7 @@ A practical guide to creating iOS Shortcuts that export your Apple Health data t
- Apple Watch (recommended for comprehensive data)
- iOS Shortcuts app (pre-installed)
- Your API endpoint: `https://api.anuragd.me`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call replacing the hardcoded token with ${API_TOKEN} placeholder. The PR description mentions rotating the previously exposed credential before production - make sure that happens as part of the rollout checklist.

}) as unknown as MessageBatch<WhoopQueueMessage>;

const TEST_WHOOP_CLIENT_SECRET = "test-whoop-client-secret";
const encoder = new TextEncoder();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signedWebhook helper properly computes the HMAC over timestamp + body which matches the verification logic. This is a nice test utility that ensures webhook tests actually exercise the real signature verification path.

@Aarekaz
Aarekaz marked this pull request as ready for review August 21, 2026 15:46
@Aarekaz
Aarekaz merged commit d9b8731 into master Aug 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant