@molecule/api-wearable-fitbit
v1.0.2
Published
Fitbit Web API bond for molecule.dev — daily activity, sleep, heart-rate, and weight ingestion via OAuth 2.0 PKCE
Maintainers
Readme
@molecule/api-wearable-fitbit
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Fitbit Web API bond for @molecule/api-wearable.
Implements daily activity, sleep, heart-rate, and weight ingestion
against the Fitbit Web API using OAuth 2.0 PKCE with refresh-token
rotation. Wires under the wearable named-multi-provider category as
'fitbit'.
Quick Start
import { setProvider } from '@molecule/api-wearable'
import { createProvider, PROVIDER_NAME } from '@molecule/api-wearable-fitbit'
const fitbit = createProvider({
redirectUri: 'https://app.example.com/auth/fitbit/callback',
credentialsStore: myCredentialsStore,
codeVerifierStore: myVerifierStore,
})
setProvider(PROVIDER_NAME, fitbit)Type
provider
Installation
npm install @molecule/api-wearable-fitbit @molecule/api-bond @molecule/api-http @molecule/api-secrets @molecule/api-wearableAPI
Interfaces
DailyActivity
Daily activity rollup. All fields are zero-defaulted by the bond when the provider does not return a value, so handlers can sum across days without null-checks.
interface DailyActivity {
/** Calendar day this rollup covers (`YYYY-MM-DD`). */
date: WearableDate
/** Total step count for the day. */
steps: number
/** Total distance traveled, in meters. */
distanceMeters: number
/** Total calories burned (active + BMR), in kilocalories. */
caloriesOut: number
/** Active minutes (provider-specific intensity definitions). */
activeMinutes: number
/** Floors climbed, when reported. */
floors?: number
/** Total elevation gain, in meters, when reported. */
elevationMeters?: number
/** Resting heart-rate, in beats per minute, when reported. */
restingHeartRate?: number
}FitbitAuthorizeStart
Result of {@link FitbitProvider.startAuthorize} — the URL the host
should redirect the user to plus the state value to round-trip.
interface FitbitAuthorizeStart {
/** Fitbit OAuth authorize URL. */
url: string
/** Opaque `state` value the host MUST verify on the callback. */
state: string
}FitbitCodeVerifierStore
Cache of code_verifier strings keyed by the in-flight authorization
code string they were paired with.
The Fitbit OAuth callback hands the bond an authorization code but
not the code_verifier it was created with — the bond needs to store
the verifier between {@link FitbitProvider.startAuthorize} and
{@link FitbitProvider.connect}. The store contract leaves the choice
of backing storage (Redis, Postgres, in-memory for tests) to the host.
interface FitbitCodeVerifierStore {
/**
* Persists the verifier indexed by the `state` value the bond will
* round-trip through Fitbit's authorize redirect.
*
* @param state - Opaque CSRF-protection token returned in the redirect.
* @param verifier - The PKCE `code_verifier` to retrieve later.
*/
put(state: string, verifier: string): Promise<void>
/**
* Retrieves and deletes the verifier associated with `state`.
* Implementations MUST delete on read so a verifier is never reused.
*
* @param state - The `state` value extracted from the OAuth callback.
* @returns The verifier, or `null` if no entry exists.
*/
take(state: string): Promise<string | null>
}FitbitProvider
Public surface returned by {@link createProvider}. Extends the stack-neutral {@link import('@molecule/api-wearable').WearableProvider} with Fitbit-specific OAuth-flow helpers.
interface FitbitProvider extends WearableProviderType {
/**
* Builds the Fitbit authorize URL and persists a fresh PKCE verifier.
*
* @returns The URL to redirect to and the round-trip `state` value.
*/
startAuthorize(): Promise<FitbitAuthorizeStart>
/**
* Performs the authorization-code → token exchange with an explicit
* PKCE `code_verifier`. Use this when the host wires the OAuth flow
* itself (no `codeVerifierStore` configured).
*
* @param userId - Host-app user identifier.
* @param code - OAuth authorization `code` from the redirect callback.
* @param verifier - The PKCE `code_verifier` used to derive the challenge.
* @returns The freshly-minted user connection (already persisted).
*/
connectWithVerifier(
userId: string,
code: string,
verifier: string,
): Promise<WearableUserConnection>
}FitbitProviderOptions
Configuration options for {@link createProvider}.
interface FitbitProviderOptions {
/**
* OAuth client id. Defaults to `process.env.OAUTH_FITBIT_CLIENT_ID`.
*/
clientId?: string
/**
* OAuth client secret. Defaults to
* `process.env.OAUTH_FITBIT_CLIENT_SECRET`. Optional — PKCE-only public
* clients omit it.
*/
clientSecret?: string
/**
* OAuth redirect URI registered with the Fitbit application. Required
* to complete the authorization-code exchange.
*/
redirectUri: string
/**
* Persists user connections (access + refresh tokens). Required.
*/
credentialsStore: WearableCredentialsStore
/**
* Optional store for PKCE `code_verifier` strings keyed by `state`.
* Required if the bond's `startAuthorize` / `connect` helpers are used
* to drive the OAuth flow; the host may omit it when wiring the OAuth
* exchange themselves.
*/
codeVerifierStore?: FitbitCodeVerifierStore
/**
* OAuth scopes to request when starting authorization. Defaults to the
* union needed for activity, sleep, heart-rate, and weight reads.
*/
scopes?: readonly string[]
/**
* Override the Fitbit Web API base URL. Defaults to
* `https://api.fitbit.com/1`.
*/
apiBaseUrl?: string
/**
* Override the Fitbit OAuth authorize endpoint. Defaults to
* `https://www.fitbit.com/oauth2/authorize`.
*/
authorizeUrl?: string
/**
* Override the Fitbit OAuth token endpoint. Defaults to
* `https://api.fitbit.com/oauth2/token`.
*/
tokenUrl?: string
/**
* Override the Fitbit OAuth token-revoke endpoint. Defaults to
* `https://api.fitbit.com/oauth2/revoke`.
*/
revokeUrl?: string
/**
* Request timeout in milliseconds. Defaults to `15_000`.
*/
timeoutMs?: number
/**
* Optional override for the random-bytes generator used by PKCE / CSRF.
* Tests inject a deterministic generator.
*/
randomBytes?: (size: number) => Uint8Array
/**
* Optional clock override, primarily for tests. Defaults to `Date.now`.
*/
now?: () => number
}HeartRateSummary
Daily heart-rate summary.
interface HeartRateSummary {
/** Calendar day this summary covers (`YYYY-MM-DD`). */
date: WearableDate
/** Resting heart-rate, in beats per minute, when reported. */
restingHeartRate?: number
/** Per-zone breakdown, when reported. */
zones?: HeartRateZone[]
}HeartRateZone
Heart-rate zone definition (rest/fat-burn/cardio/peak or provider-named).
interface HeartRateZone {
/** Zone label as reported by the provider. */
name: string
/** Lower bound of the zone, in beats per minute (inclusive). */
minBpm: number
/** Upper bound of the zone, in beats per minute (exclusive). */
maxBpm: number
/** Minutes spent in the zone for this period. */
minutes: number
/** Calories burned in the zone, in kilocalories. */
caloriesOut?: number
}SleepSession
One sleep session. Most users have exactly one per night, but providers report naps as separate sessions, so handlers must sum across the array.
interface SleepSession {
/** Provider-specific session id. */
id: string
/** Calendar day this session is bucketed under (`YYYY-MM-DD`). */
date: WearableDate
/** ISO 8601 sleep start. */
start: string
/** ISO 8601 sleep end. */
end: string
/** Total time in bed, in minutes. */
timeInBedMinutes: number
/** Total time asleep, in minutes (excludes `awake` segments). */
timeAsleepMinutes: number
/** Sleep efficiency percentage (0-100). */
efficiency?: number
/** Whether this session is the user's primary sleep for the day. */
isMainSleep: boolean
/** Per-stage minute totals when the provider reports stages. */
stageSummary?: SleepStageSummary
/** Per-segment stage breakdown when the provider reports stages. */
segments?: SleepStageSegment[]
}SleepStageSegment
A contiguous block of a single sleep stage within a sleep session.
interface SleepStageSegment {
/** Normalized stage. */
stage: SleepStage
/** ISO 8601 segment start. */
start: string
/** ISO 8601 segment end. */
end: string
/** Segment duration in seconds (provider-reported when available). */
durationSeconds: number
}SleepStageSummary
Per-stage totals for a sleep session, in minutes. Optional — providers
that only report the coarse "asleep" classification will omit
light/deep/rem.
interface SleepStageSummary {
/** Minutes spent awake during the session. */
awakeMinutes?: number
/** Minutes in light sleep. */
lightMinutes?: number
/** Minutes in deep sleep. */
deepMinutes?: number
/** Minutes in REM sleep. */
remMinutes?: number
/** Minutes restless (legacy classifications). */
restlessMinutes?: number
}UserConnection
Per-user OAuth tokens minted by {@link WearableProvider.connect} and rotated by {@link WearableProvider.refreshConnection}. Bonds persist these via a caller-supplied {@link WearableCredentialsStore}.
Token strings are NEVER thrown, logged, or echoed back in error messages — implementations must sanitize all error paths.
interface UserConnection {
/** The user owning the connection in the host application. */
userId: string
/** Provider-specific account identifier (e.g. Fitbit `user_id`). */
providerAccountId: string
/** Current access token. */
accessToken: string
/** Refresh token used to mint new access tokens. */
refreshToken: string
/** Optional epoch-millis timestamp at which the access token expires. */
expiresAt?: number
/** Granted OAuth scopes (provider-specific names). */
scopes?: string[]
/** Epoch-millis timestamp at which the connection was first established. */
connectedAt: number
}WearableCredentialsStore
Persistence contract for {@link UserConnection} records. Implementations are responsible for at-rest encryption (refresh tokens are bearer credentials and MUST be stored securely).
The same store can back any number of provider bonds (Fitbit, Oura,
Withings, etc.) — segregation is by (userId, providerName) pair.
interface WearableCredentialsStore {
/**
* Looks up the connection for `(userId, providerName)`.
*
* @param userId - Host-app user identifier.
* @param providerName - Provider key, e.g. `"fitbit"`.
* @returns The stored connection or `null` if none.
*/
read(userId: string, providerName: string): Promise<UserConnection | null>
/**
* Persists a new or refreshed connection.
*
* @param providerName - Provider key, e.g. `"fitbit"`.
* @param connection - The connection record to write.
*/
write(providerName: string, connection: UserConnection): Promise<void>
/**
* Deletes the connection for `(userId, providerName)`.
*
* @param userId - Host-app user identifier.
* @param providerName - Provider key.
*/
remove(userId: string, providerName: string): Promise<void>
}WearableDateRange
Inclusive date range.
interface WearableDateRange {
/** Lower-bound calendar day (`YYYY-MM-DD`), inclusive. */
start: WearableDate
/** Upper-bound calendar day (`YYYY-MM-DD`), inclusive. */
end: WearableDate
}WearableProvider
Wearable provider contract.
Each method takes a host-app userId rather than per-call credentials.
Implementations look up persisted {@link UserConnection} records via the
caller-supplied {@link WearableCredentialsStore}, transparently refresh
expired access tokens (writing the rotated record back to the store),
and surface refreshes as a side-effect of normal data calls.
Token-bearing values (access tokens, refresh tokens, Authorization
headers, OAuth error_description payloads that may include the token)
MUST NEVER be thrown, logged, or returned in error messages — every
error path must be sanitized.
interface WearableProvider {
/** Stable provider key used for credential-store segregation, e.g. `"fitbit"`. */
readonly providerName: string
/**
* Reads the daily activity rollup for `(userId, date)`.
*
* @param userId - Host-app user identifier.
* @param date - Calendar day (`YYYY-MM-DD`).
* @returns Normalized daily activity.
*/
getDailyActivity(userId: string, date: WearableDate): Promise<DailyActivity>
/**
* Reads sleep sessions bucketed under `(userId, date)`. Most days return
* a single primary session; multi-nap days return multiple.
*
* @param userId - Host-app user identifier.
* @param date - Calendar day (`YYYY-MM-DD`).
* @returns All sleep sessions bucketed under that day.
*/
getDailySleep(userId: string, date: WearableDate): Promise<SleepSession[]>
/**
* Reads the daily heart-rate summary for `(userId, date)`.
*
* @param userId - Host-app user identifier.
* @param date - Calendar day (`YYYY-MM-DD`).
* @returns Normalized daily heart-rate summary.
*/
getDailyHeartRate(userId: string, date: WearableDate): Promise<HeartRateSummary>
/**
* Reads body-weight entries for the user across `range`. Providers cap
* how far back a single call may reach — implementations should clamp
* `range` to the provider's documented maximum and return only the
* available entries.
*
* @param userId - Host-app user identifier.
* @param range - Inclusive calendar-day range.
* @returns Weight entries, ordered by `recordedAt` ascending.
*/
getWeight(userId: string, range: WearableDateRange): Promise<WeightEntry[]>
/**
* Exchanges an OAuth authorization `code` for tokens and persists the
* resulting {@link UserConnection} via the bond's credentials store.
*
* @param userId - Host-app user identifier (the local user accepting the link).
* @param code - Authorization code from the OAuth redirect callback.
* @returns The freshly-minted connection (already persisted).
*/
connect(userId: string, code: string): Promise<UserConnection>
/**
* Forces a refresh of the user's access token using the stored refresh
* token. The rotated connection is persisted before being returned.
*
* @param userId - Host-app user identifier.
* @returns The rotated connection (already persisted).
*/
refreshConnection(userId: string): Promise<UserConnection>
/**
* Revokes (best-effort) and removes the user's connection record.
*
* Implementations SHOULD attempt to revoke the token at the provider but
* MUST always remove the local record even if revocation fails — leaking
* a record after `disconnect` is worse than a stranded provider-side
* token.
*
* @param userId - Host-app user identifier.
*/
disconnect(userId: string): Promise<void>
}WeightEntry
One body-weight reading.
interface WeightEntry {
/** ISO 8601 timestamp at which the reading was taken. */
recordedAt: string
/** Calendar day of the reading (`YYYY-MM-DD`). */
date: WearableDate
/** Weight in kilograms. */
weightKg: number
/** Body-fat percentage (0-100), when reported. */
bodyFatPercent?: number
/** BMI (kg / m²), when reported. */
bmi?: number
/** Provider-specific entry id. */
id?: string
}Types
SleepStage
Sleep stage taxonomy normalized across providers.
awake— periods awake during a sleep sessionlight,deep,rem— modern stage classificationsrestless,asleep— legacy/coarse classifications used by some providersunknown— fallback for unmapped values
type SleepStage = 'awake' | 'light' | 'deep' | 'rem' | 'restless' | 'asleep' | 'unknown'WearableDate
Calendar-day identifier in YYYY-MM-DD format. Wearable providers
universally bucket activity/sleep/HR data by local-day, so the core
exchanges date strings (not absolute timestamps) for per-day reads.
type WearableDate = stringFunctions
base64UrlEncode(bytes)
Base64url-encodes the bytes (RFC 4648 §5 with = padding stripped).
function base64UrlEncode(bytes: Uint8Array<ArrayBufferLike>): stringbytes— The bytes to encode.
Returns: A base64url string.
createProvider(options)
Creates a Fitbit wearable provider.
function createProvider(options: FitbitProviderOptions): FitbitProvideroptions— Required:redirectUri+credentialsStore. Falls back toOAUTH_FITBIT_CLIENT_ID/OAUTH_FITBIT_CLIENT_SECRETenv vars whenclientId/clientSecretare omitted.
Returns: A Fitbit-flavored {@link FitbitProvider}.
fromFitbitActivity(date, raw)
Translates a Fitbit activities/date summary payload into the
normalized {@link DailyActivity} shape. Missing fields are zero-defaulted
so handlers can sum across days without null guards.
function fromFitbitActivity(date: string, raw: FitbitDailyActivityResponse): DailyActivitydate— The calendar day the response was requested for.raw— Fitbit's daily-activity response payload.
Returns: A normalized daily activity record.
fromFitbitHeartRate(date, raw)
Translates a Fitbit activities/heart/date payload into the normalized
{@link HeartRateSummary} shape.
function fromFitbitHeartRate(date: string, raw: FitbitHeartRateResponse): HeartRateSummarydate— Calendar day the response was requested for.raw— Fitbit's heart-rate response payload.
Returns: A normalized heart-rate summary.
fromFitbitSleep(date, raw)
Translates a Fitbit sleep/date payload into a list of normalized
{@link SleepSession} records.
function fromFitbitSleep(date: string, raw: FitbitSleepResponse): SleepSession[]date— Calendar day the response was requested for.raw— Fitbit's sleep response payload.
Returns: Normalized sleep sessions ordered by start ascending.
fromFitbitWeight(raw)
Translates a Fitbit weight-log payload into normalized
{@link WeightEntry} records ordered by recordedAt ascending.
function fromFitbitWeight(raw: FitbitWeightResponse): WeightEntry[]raw— Fitbit's weight response payload.
Returns: Normalized weight entries.
mapSleepStage(level)
Maps Fitbit's per-segment level string onto our normalized
{@link SleepStage} taxonomy. Both classic and stages payloads are
supported; unknown values fall through to unknown.
function mapSleepStage(level: string): SleepStagelevel— The Fitbit sleep level string.
Returns: A normalized sleep stage.
sha256Base64Url(input)
SHA-256-hashes the input string and base64url-encodes the digest. Used
to derive the PKCE code_challenge from the code_verifier.
function sha256Base64Url(input: string): Promise<string>input— The string to hash.
Returns: A base64url-encoded SHA-256 digest.
Constants
PROVIDER_NAME
Stable provider key used in the credentials store and bond name.
const PROVIDER_NAME: 'fitbit'wearableFitbitSecretDefinitions
Secret definitions required by the Fitbit wearable bond.
const wearableFitbitSecretDefinitions: SecretDefinition[]Core Interface
Implements @molecule/api-wearable interface.
Injection Notes
Requirements
Peer dependencies:
@molecule/api-bond^1.0.1@molecule/api-http^1.0.1@molecule/api-secrets^1.0.1@molecule/api-wearable^1.0.1
Environment Variables
OAUTH_FITBIT_CLIENT_ID(required) — Fitbit OAuth client ID- Setup: Register an application at dev.fitbit.com and copy the OAuth 2.0 Client ID.
- Get it here: https://dev.fitbit.com/apps
OAUTH_FITBIT_CLIENT_SECRET(optional) — Fitbit client secret- Setup: Shown on your registered Fitbit application page.
- Get it here: https://dev.fitbit.com/apps
Runtime Dependencies
@molecule/api-bond@molecule/api-http@molecule/api-secrets@molecule/api-wearable
OAuth callback contract for connect(userId, code): startAuthorize()
stores the PKCE verifier under the returned state, but connect() looks
it up by the authorization code. Your callback handler must bridge the
two: validate state, take(state) the verifier, put(code, verifier)
it back, then call connect(userId, code) — or skip the store entirely
and call connectWithVerifier(userId, code, verifier). Calling connect()
without the re-put fails with 'no PKCE verifier found for supplied code'.
Tokens refresh transparently (proactively on expiry, once on 401) and are
written back to the credentials store before any call returns.
disconnect() always removes the local record even if Fitbit's revoke
call fails.
E2E Tests
Integration checklist — drive the real UI (live preview), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip. CAVEAT: the device vendor's OAuth consent screen and real sensor data can't be driven in the sandbox, so verify the connection lifecycle + the data mapping/display you own by wiring a stub/test provider (or feeding a sample payload) where the real grant/sync would occur — never mock the app's own handlers or UI:
- [ ] Connecting a device from the UI runs the provider's
connect(userId, code), which exchanges the OAuth code and persists aUserConnectionvia theWearableCredentialsStore(store.write), keyed by(userId, providerName). Afterwardstore.read(userId, name)returns that connection and the device shows "connected";disconnect(userId)removes the record and the UI returns to the unlinked state. - [ ] Fetching a metric for a real date renders plausible, in-range values in a
chart/summary:
getDailyActivity().stepsin the thousands,getDailyHeartRate()restingHeartRate~40-200 bpm,getDailySleep()timeAsleepMinutesa sane number of hours,getWeight()weightKga human bodyweight — never null/NaN/negative. - [ ] Different days/ranges return different data and the dates line up with no
off-by-one/timezone shift: each rollup's
date(aYYYY-MM-DDWearableDate) equals the day requested, and everygetWeight(range)entry'sdatefalls withinrange.start..range.endinclusive. - [ ] A day the device wasn't worn/synced shows as a GAP, not a celebrated zero.
The core zero-defaults
DailyActivity(a missing day comes back assteps: 0,activeMinutes: 0), so the UI must distinguish "no data" from a real 0 and never present an unsynced day as "0 steps achieved". - [ ] This core is pull-based — the
WearableProviderinterface has no webhook method; data is read on demand via thegetDaily*/getWeightcalls. If a provider bond wires a sync/subscription callback, delivering a valid callback updates the stored data and a forged/unsigned callback is rejected. - [ ] PRIVACY/SECURITY — health data is per-user: every
getDaily*/getWeightcall is scoped by the caller's authenticateduserIdand the store is segregated by(userId, providerName), so no id-guessing reaches another user's metrics. Device tokens (accessToken/refreshToken) and provider keys stay server-side (the package is server-only) and are never logged in the clear — error paths are sanitized so no token or health data leaks into logs.
