← All @molecule/* packages · App templates
@molecule/api-wearable-ouraProvider bond · wearable · API (Node) · v1.0.1 · Apache-2.0
Oura Ring Cloud API v2 bond for molecule.dev — daily activity, sleep (REM/deep/light), and heart-rate via OAuth 2.0. Returns empty weight data — Oura does not track body weight.
npm install @molecule/api-wearable-ouranpm · Source on GitHub · Implements @molecule/api-wearable
@molecule/api-wearable-oura is a provider bond on the API (Node) side: it implements the wearable core interface (@molecule/api-wearable) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/api-wearable'
import { createProvider, PROVIDER_NAME } from '@molecule/api-wearable-oura'
const oura = createProvider({
redirectUri: 'https://app.example.com/auth/oura/callback',
credentialsStore: myCredentialsStore,
})
setProvider(PROVIDER_NAME, oura)Works with: @molecule/api-bond, @molecule/api-http, @molecule/api-secrets, @molecule/api-wearable
Secrets: OAUTH_OURA_CLIENT_ID, OAUTH_OURA_CLIENT_SECRET
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.
Oura Cloud API v2 bond for @molecule/api-wearable.
Implements daily activity, sleep, and heart-rate ingestion against the
Oura Cloud API v2 using OAuth 2.0 with refresh-token rotation. Wires
under the wearable named-multi-provider category as 'oura'.
Oura does not track body weight, so {@link createProvider} returns
[] from getWeight() — pair Oura with another wearable bond
(e.g. @molecule/api-wearable-fitbit or -withings) when weight
data is required.
import { setProvider } from '@molecule/api-wearable'
import { createProvider, PROVIDER_NAME } from '@molecule/api-wearable-oura'
const oura = createProvider({
redirectUri: 'https://app.example.com/auth/oura/callback',
credentialsStore: myCredentialsStore,
})
setProvider(PROVIDER_NAME, oura)
provider
npm install @molecule/api-wearable-oura @molecule/api-bond @molecule/api-http @molecule/api-secrets @molecule/api-wearable
DailyActivityDaily 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
}
HeartRateSummaryDaily 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[]
}
HeartRateZoneHeart-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
}
OuraAuthorizeStartResult of {@link OuraProvider.startAuthorize} — the URL the host
should redirect the user to plus the state value to round-trip.
interface OuraAuthorizeStart {
/** Oura OAuth authorize URL. */
url: string
/** Opaque `state` value the host MUST verify on the callback. */
state: string
}
OuraProviderPublic surface returned by {@link createProvider}. Extends the
stack-neutral {@link import('@molecule/api-wearable').WearableProvider}
with an Oura-flavored startAuthorize() helper for hosts that want
the bond to build the authorize URL.
interface OuraProvider extends WearableProviderType {
/**
* Builds the Oura authorize URL with a random `state` value. The host
* is responsible for round-tripping `state` through the OAuth callback
* and verifying it before calling {@link OuraProvider.connect}.
*
* @returns The URL to redirect to and the `state` value.
*/
startAuthorize(): OuraAuthorizeStart
}
OuraProviderOptionsConfiguration options for {@link createProvider}.
interface OuraProviderOptions {
/**
* OAuth client id. Defaults to `process.env.OAUTH_OURA_CLIENT_ID`.
*/
clientId?: string
/**
* OAuth client secret. Defaults to
* `process.env.OAUTH_OURA_CLIENT_SECRET`.
*/
clientSecret?: string
/**
* OAuth redirect URI registered with the Oura application.
*/
redirectUri: string
/**
* Persists user connections (access + refresh tokens). Required.
*/
credentialsStore: WearableCredentialsStore
/**
* OAuth scopes to request when starting authorization. Defaults to the
* union needed for daily activity, sleep, heart-rate, and personal
* info reads.
*/
scopes?: readonly string[]
/**
* Override the Oura Cloud API base URL. Defaults to
* `https://api.ouraring.com/v2`.
*/
apiBaseUrl?: string
/**
* Override the Oura OAuth authorize endpoint. Defaults to
* `https://cloud.ouraring.com/oauth/authorize`.
*/
authorizeUrl?: string
/**
* Override the Oura OAuth token endpoint. Defaults to
* `https://api.ouraring.com/oauth/token`.
*/
tokenUrl?: string
/**
* Override the Oura OAuth token-revoke endpoint. Defaults to
* `https://api.ouraring.com/oauth/revoke`.
*/
revokeUrl?: string
/**
* Request timeout in milliseconds. Defaults to `15_000`.
*/
timeoutMs?: number
/**
* Optional override for the random-bytes generator used for the OAuth
* `state` parameter. Tests inject a deterministic generator.
*/
randomBytes?: (size: number) => Uint8Array
/**
* Optional clock override, primarily for tests. Defaults to `Date.now`.
*/
now?: () => number
}
SleepSessionOne 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[]
}
SleepStageSegmentA 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
}
SleepStageSummaryPer-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
}
UserConnectionPer-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
}
WearableCredentialsStorePersistence 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>
}
WearableDateRangeInclusive date range.
interface WearableDateRange {
/** Lower-bound calendar day (`YYYY-MM-DD`), inclusive. */
start: WearableDate
/** Upper-bound calendar day (`YYYY-MM-DD`), inclusive. */
end: WearableDate
}
WearableProviderWearable 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>
}
WeightEntryOne 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
}
SleepStageSleep 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 valuestype SleepStage = 'awake' | 'light' | 'deep' | 'rem' | 'restless' | 'asleep' | 'unknown'
WearableDateCalendar-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 = string
base64UrlEncode(bytes)Base64url-encodes the bytes (RFC 4648 §5 with = padding stripped).
function base64UrlEncode(bytes: Uint8Array<ArrayBufferLike>): string
bytes — The bytes to encode.Returns: A base64url string.
createProvider(options)Creates an Oura wearable provider.
function createProvider(options: OuraProviderOptions): OuraProvider
options — Required: redirectUri + credentialsStore. Falls back to OAUTH_OURA_CLIENT_ID / OAUTH_OURA_CLIENT_SECRET env vars when clientId / clientSecret are omitted.Returns: An Oura-flavored {@link OuraProvider}.
decodeOuraHypnogram(hypnogram, bedtimeStart)Decodes Oura's sleep_phase_5_min hypnogram into a list of contiguous
{@link SleepStageSegment} records anchored at bedtimeStart.
Each character represents a 5-minute window. Adjacent same-stage windows are coalesced into a single segment so consumers don't have to do the bookkeeping.
function decodeOuraHypnogram(
hypnogram: string | undefined,
bedtimeStart: string,
): SleepStageSegment[] | undefined
hypnogram — The Oura hypnogram string.bedtimeStart — ISO 8601 timestamp anchoring segment 0.Returns: Decoded sleep segments, or undefined when no hypnogram exists.
fromOuraActivity(date, entry)Translates an Oura usercollection/daily_activity entry into the
normalized {@link DailyActivity} shape. Missing fields are
zero-defaulted so handlers can sum across days without null guards.
function fromOuraActivity(date: string, entry: OuraDailyActivityEntry | undefined): DailyActivity
date — The calendar day requested.entry — Oura's daily-activity response entry, or undefined.Returns: A normalized daily activity record.
fromOuraHeartRate(date, raw)Aggregates an Oura usercollection/heartrate payload into the
normalized {@link HeartRateSummary} shape.
Oura returns per-sample BPM readings rather than a daily resting-HR
value or zone breakdown; the bond derives a coarse resting-HR estimate
from the minimum sample of the day. Zone data is not reported by this
endpoint, so zones is left undefined.
function fromOuraHeartRate(date: string, raw: OuraHeartRateResponse): HeartRateSummary
date — Calendar day the response was requested for.raw — Oura's heart-rate response payload.Returns: A normalized heart-rate summary.
fromOuraSleep(date, raw)Translates an Oura usercollection/sleep payload into a list of
normalized {@link SleepSession} records ordered by start ascending.
Oura's type === 'long_sleep' corresponds to the user's main nightly
sleep — the bond marks any matching session as isMainSleep: true,
with naps and short sessions as false.
function fromOuraSleep(date: string, raw: OuraSleepResponse): SleepSession[]
date — Calendar day requested.raw — Oura's sleep response payload.Returns: Normalized sleep sessions.
mapSleepPhaseDigit(digit)Maps an Oura sleep_phase_5_min digit onto our normalized
{@link SleepStage} taxonomy. Oura uses 1=deep, 2=light, 3=rem,
4=awake; anything else falls through to unknown.
function mapSleepPhaseDigit(digit: string): SleepStage
digit — The single hypnogram character.Returns: A normalized sleep stage.
PROVIDER_NAMEStable provider key used in the credentials store and bond name.
const PROVIDER_NAME: 'oura'
wearableOuraSecretDefinitionsSecret definitions required by the Oura wearable bond.
const wearableOuraSecretDefinitions: SecretDefinition[]
Implements @molecule/api-wearable interface.
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.1OAUTH_OURA_CLIENT_ID (required) — Oura OAuth client ID
OAUTH_OURA_CLIENT_SECRET (required) — Oura client secret
@molecule/api-bond@molecule/api-http@molecule/api-secrets@molecule/api-wearableIntegration 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:
connect(userId, code),
which exchanges the OAuth code and persists a UserConnection via the
WearableCredentialsStore (store.write), keyed by (userId, providerName).
Afterward store.read(userId, name) returns that connection and the device
shows "connected"; disconnect(userId) removes the record and the UI returns
to the unlinked state.getDailyActivity().steps in the thousands, getDailyHeartRate()
restingHeartRate ~40-200 bpm, getDailySleep() timeAsleepMinutes a sane
number of hours, getWeight() weightKg a human bodyweight — never
null/NaN/negative.date (a YYYY-MM-DD WearableDate)
equals the day requested, and every getWeight(range) entry's date falls
within range.start..range.end inclusive.DailyActivity (a missing day comes back as steps: 0,
activeMinutes: 0), so the UI must distinguish "no data" from a real 0 and
never present an unsynced day as "0 steps achieved".WearableProvider interface has no webhook
method; data is read on demand via the getDaily*/getWeight calls. If a
provider bond wires a sync/subscription callback, delivering a valid callback
updates the stored data and a forged/unsigned callback is rejected.getDaily*/getWeight
call is scoped by the caller's authenticated userId and 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.