← back to AbramsOS
withings: device-vitals auto-sync connector (OAuth2 → weight/BP/HR/SpO2 into Health)
567188579bf5ad6d59ba29ac20ec38bf46730257 · 2026-07-13 09:56:02 -0700 · Steve
- lib/withings-client.js: Withings Health API OAuth (authUrl/exchange/refresh) + getMeasures mapping (type 1=weight kg→lb, 9/10=BP pair, 11=HR, 54=SpO2); handles refresh-token rotation; graceful no-creds
- routes/withings.js: /auth/withings → consent → /api/withings/callback stores encrypted token in connector_account (provider=withings) + initial sync; /api/withings/sync re-syncs (dedup via health_reading external_id); reuses vitals.insertReadings; audited medical
- connectors.ejs: 'Health devices' section — Connect Withings / Sync now / setup-needed
- server.js wiring + .env WITHINGS_* (gated). One-set-of-creds-from-working
Files touched
A lib/withings-client.jsA routes/withings.jsM server.jsM views/connectors.ejs
Diff
commit 567188579bf5ad6d59ba29ac20ec38bf46730257
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 13 09:56:02 2026 -0700
withings: device-vitals auto-sync connector (OAuth2 → weight/BP/HR/SpO2 into Health)
- lib/withings-client.js: Withings Health API OAuth (authUrl/exchange/refresh) + getMeasures mapping (type 1=weight kg→lb, 9/10=BP pair, 11=HR, 54=SpO2); handles refresh-token rotation; graceful no-creds
- routes/withings.js: /auth/withings → consent → /api/withings/callback stores encrypted token in connector_account (provider=withings) + initial sync; /api/withings/sync re-syncs (dedup via health_reading external_id); reuses vitals.insertReadings; audited medical
- connectors.ejs: 'Health devices' section — Connect Withings / Sync now / setup-needed
- server.js wiring + .env WITHINGS_* (gated). One-set-of-creds-from-working
---
lib/withings-client.js | 74 +++++++++++++++++++++++++++++++++++
routes/withings.js | 102 +++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 2 +
views/connectors.ejs | 35 +++++++++++++++++
4 files changed, 213 insertions(+)
diff --git a/lib/withings-client.js b/lib/withings-client.js
new file mode 100644
index 0000000..5f2c09e
--- /dev/null
+++ b/lib/withings-client.js
@@ -0,0 +1,74 @@
+// withings-client.js — Withings Health API (OAuth2) client for auto-syncing device
+// readings (weight, blood pressure, heart rate, SpO2) into the Vitals module.
+//
+// One-set-of-creds-from-working: register an app at developer.withings.com → set
+// WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET (+ optional WITHINGS_REDIRECT_URI). No creds
+// → graceful "not configured". Withings ROTATES the refresh token on every refresh, so the
+// caller must persist the new refresh_token each sync. Never fabricates a reading.
+
+const CLIENT_ID = () => process.env.WITHINGS_CLIENT_ID;
+const CLIENT_SECRET = () => process.env.WITHINGS_CLIENT_SECRET;
+const REDIRECT = () => process.env.WITHINGS_REDIRECT_URI || 'http://localhost:9774/api/withings/callback';
+
+const AUTH_URL = 'https://account.withings.com/oauth2_user/authorize2';
+const TOKEN_URL = 'https://wbsapi.withings.net/v2/oauth2';
+const MEAS_URL = 'https://wbsapi.withings.net/measure';
+
+function isConfigured() {
+ return Boolean(CLIENT_ID() && CLIENT_SECRET());
+}
+
+function authUrl(state) {
+ const p = new URLSearchParams({
+ response_type: 'code', client_id: CLIENT_ID(), scope: 'user.metrics',
+ redirect_uri: REDIRECT(), state: state || 'withings',
+ });
+ return `${AUTH_URL}?${p.toString()}`;
+}
+
+async function tokenRequest(extra) {
+ const body = new URLSearchParams({
+ action: 'requesttoken', client_id: CLIENT_ID(), client_secret: CLIENT_SECRET(), ...extra,
+ });
+ const r = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
+ const j = await r.json();
+ if (j.status !== 0) throw new Error(`withings token error ${j.status} ${j.error || ''}`.trim());
+ return j.body; // { userid, access_token, refresh_token, expires_in, ... }
+}
+
+function exchangeCode(code) {
+ return tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT() });
+}
+function refresh(refresh_token) {
+ return tokenRequest({ grant_type: 'refresh_token', refresh_token });
+}
+
+// Withings measure type → our metric. BP comes as a group containing 9 + 10 (+ often 11).
+const REAL = (m) => m.value * Math.pow(10, m.unit);
+
+async function getMeasures(access_token, lastUpdateEpoch) {
+ const body = new URLSearchParams({ action: 'getmeas', access_token, meastypes: '1,9,10,11,54', category: '1' });
+ if (lastUpdateEpoch) body.set('lastupdate', String(lastUpdateEpoch));
+ const r = await fetch(MEAS_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body });
+ const j = await r.json();
+ if (j.status !== 0) throw new Error(`withings measure error ${j.status}`);
+
+ const readings = [];
+ let newest = lastUpdateEpoch || 0;
+ for (const g of (j.body.measuregrps || [])) {
+ if (g.date > newest) newest = g.date;
+ const when = new Date(g.date * 1000);
+ const t = {};
+ for (const m of g.measures) t[m.type] = REAL(m);
+ const dev = { source: 'device', device_name: 'Withings', measured_at: when };
+ if (t[10] != null && t[9] != null) {
+ readings.push({ metric: 'blood_pressure', systolic: t[10], diastolic: t[9], unit: 'mmHg', external_id: `withings:bp:${g.grpid}`, ...dev });
+ }
+ if (t[1] != null) readings.push({ metric: 'weight', value: Math.round(t[1] * 2.20462 * 10) / 10, unit: 'lb', external_id: `withings:wt:${g.grpid}`, ...dev });
+ if (t[11] != null) readings.push({ metric: 'heart_rate', value: t[11], unit: 'bpm', external_id: `withings:hr:${g.grpid}`, ...dev });
+ if (t[54] != null) readings.push({ metric: 'spo2', value: t[54] <= 1 ? t[54] * 100 : t[54], unit: '%', external_id: `withings:spo2:${g.grpid}`, ...dev });
+ }
+ return { readings, newest };
+}
+
+module.exports = { isConfigured, authUrl, exchangeCode, refresh, getMeasures, REDIRECT };
diff --git a/routes/withings.js b/routes/withings.js
new file mode 100644
index 0000000..b5edf35
--- /dev/null
+++ b/routes/withings.js
@@ -0,0 +1,102 @@
+// Withings — connect a Withings account (OAuth2) and auto-sync device readings
+// (weight, BP, heart rate, SpO2) into health_reading. Token stored encrypted in
+// connector_account (provider='withings'); Withings rotates the refresh token each
+// refresh, so we re-persist it every sync. Sensitive medical data; audited.
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const { encrypt, decrypt } = require('../lib/crypto');
+const w = require('../lib/withings-client');
+const { insertReadings } = require('./vitals');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function getConnector() {
+ const r = await db.query(
+ `SELECT id, external_subject_id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag, sync_cursor, last_sync_at
+ FROM connector_account WHERE user_id = $1 AND provider = 'withings' LIMIT 1`, [DEV_USER_ID]);
+ return r.rows[0] || null;
+}
+async function selfPersonId() {
+ const r = await db.query(`SELECT id FROM person WHERE user_id=$1 AND relation='self' LIMIT 1`, [DEV_USER_ID]);
+ return r.rows[0] && r.rows[0].id;
+}
+
+router.get('/api/withings/status', async (_req, res) => {
+ const c = await getConnector();
+ res.json({ configured: w.isConfigured(), connected: !!c, last_sync_at: c && c.last_sync_at });
+});
+
+// 1) Kick off OAuth — send the browser to Withings' consent screen.
+router.get('/auth/withings', (_req, res) => {
+ if (!w.isConfigured()) return res.status(501).send('Withings not configured — set WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET.');
+ res.redirect(w.authUrl('withings'));
+});
+
+// 2) Withings redirects back with a code → exchange, store, initial sync.
+router.get('/api/withings/callback', async (req, res) => {
+ const { code } = req.query;
+ if (!code) return res.status(400).send('No authorization code');
+ try {
+ const tok = await w.exchangeCode(code);
+ await storeToken(tok);
+ const synced = await runSync().catch(() => ({ inserted: 0 }));
+ res.redirect(`/health?withings=connected&added=${synced.inserted || 0}`);
+ } catch (err) {
+ res.status(502).send(`Withings connect failed: ${err.message}`);
+ }
+});
+
+// 3) Manual re-sync.
+router.post('/api/withings/sync', async (_req, res) => {
+ const c = await getConnector();
+ if (!c) return res.status(404).json({ ok: false, error: 'Withings not connected' });
+ try {
+ const out = await runSync();
+ res.json({ ok: true, ...out });
+ } catch (err) {
+ res.status(502).json({ ok: false, error: err.message });
+ }
+});
+
+async function storeToken(tok, connectorId) {
+ const enc = encrypt(tok.refresh_token);
+ if (connectorId) {
+ await db.query(
+ `UPDATE connector_account SET refresh_token_encrypted=$1, refresh_token_iv=$2, refresh_token_tag=$3 WHERE id=$4`,
+ [enc.ciphertext, enc.iv, enc.tag, connectorId]);
+ return connectorId;
+ }
+ const consentId = id('consent'); const cid = id('connector');
+ await db.tx(async (client) => {
+ await client.query(`INSERT INTO consent_grant (id, user_id, connector_type, scopes_json) VALUES ($1,$2,'withings',$3)`,
+ [consentId, DEV_USER_ID, JSON.stringify({ scope: 'user.metrics' })]);
+ await client.query(
+ `INSERT INTO connector_account (id, user_id, consent_grant_id, provider, external_subject_id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
+ VALUES ($1,$2,$3,'withings',$4,$5,$6,$7)
+ ON CONFLICT (provider, external_subject_id) DO UPDATE SET
+ refresh_token_encrypted=EXCLUDED.refresh_token_encrypted, refresh_token_iv=EXCLUDED.refresh_token_iv,
+ refresh_token_tag=EXCLUDED.refresh_token_tag, consent_grant_id=EXCLUDED.consent_grant_id`,
+ [cid, DEV_USER_ID, consentId, String(tok.userid || 'withings'), enc.ciphertext, enc.iv, enc.tag]);
+ });
+ return cid;
+}
+
+async function runSync() {
+ const c = await getConnector();
+ if (!c) throw new Error('not connected');
+ const refreshToken = decrypt(c.refresh_token_encrypted, c.refresh_token_iv, c.refresh_token_tag);
+ const tok = await w.refresh(refreshToken); // rotates the refresh token
+ await storeToken(tok, c.id); // re-persist the new one
+ const since = c.sync_cursor ? Number(c.sync_cursor) : null;
+ const { readings, newest } = await w.getMeasures(tok.access_token, since);
+ const personId = await selfPersonId();
+ const out = await insertReadings(DEV_USER_ID, personId, readings.map((r) => ({ ...r })));
+ await db.query(`UPDATE connector_account SET last_sync_at=now(), sync_cursor=$2 WHERE id=$1`, [c.id, String(newest || since || '')]);
+ await audit.log({ actorType: 'system', actorId: DEV_USER_ID, objectType: 'connector_account', objectId: c.id, eventType: 'withings_sync', metadata: { medical: true, fetched: readings.length, inserted: out.inserted } }).catch(() => {});
+ return { fetched: readings.length, inserted: out.inserted, skipped: out.skipped };
+}
+
+module.exports = router;
diff --git a/server.js b/server.js
index a901eaf..dac508a 100644
--- a/server.js
+++ b/server.js
@@ -29,6 +29,7 @@ const vitalsRouter = require('./routes/vitals').router;
const assetsRouter = require('./routes/assets');
const neighborhoodRouter = require('./routes/neighborhood');
const biometricsRouter = require('./routes/biometrics');
+const withingsRouter = require('./routes/withings');
const warrantiesRouter = require('./routes/warranties');
const peopleRouter = require('./routes/people');
const medicationsRouter = require('./routes/medications');
@@ -97,6 +98,7 @@ app.use(vitalsRouter); // /health, /api/health/readings*
app.use(assetsRouter); // /assets, /api/assets*
app.use(neighborhoodRouter); // /neighborhood, /api/neighborhood/geocode
app.use(biometricsRouter); // /biometrics, /api/biometrics*
+app.use(withingsRouter); // /auth/withings, /api/withings/*
app.use(warrantiesRouter); // /warranties, /api/warranties*
app.use(peopleRouter); // /household, /api/people*
app.use(medicationsRouter); // /medications, /api/medications*
diff --git a/views/connectors.ejs b/views/connectors.ejs
index 5b42ee2..2f0cad9 100644
--- a/views/connectors.ejs
+++ b/views/connectors.ejs
@@ -15,6 +15,17 @@
</div>
<div id="bankList" style="margin-top:.75rem"></div>
<div id="bankStatus" class="subtle" style="margin-top:.5rem"></div>
+</section>
+
+<section class="glass" id="withingsSection" style="padding:1.25rem 1.5rem;margin-bottom:1rem">
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap">
+ <div>
+ <h2 style="margin:.2rem 0">Health devices</h2>
+ <p class="subtle" style="margin:0">Connect <strong>Withings</strong> (smart scale / BP cuff / watch) to auto-sync weight, blood pressure, heart rate & SpO₂ into <a href="/health">Health</a>. Read-only via Withings OAuth.</p>
+ </div>
+ <div id="withAction"><span class="subtle">checking…</span></div>
+ </div>
+ <div id="withStatus" class="subtle" style="margin-top:.5rem"></div>
<hr style="border:none;border-top:1px solid rgba(128,128,128,.2);margin:1rem 0">
<div>
@@ -150,4 +161,28 @@
});
</script>
+<script>
+(async function withings(){
+ const action=document.getElementById('withAction'), s=document.getElementById('withStatus');
+ let st; try{ st=await (await fetch('/api/withings/status')).json(); }catch(e){ action.innerHTML='<span class="subtle">status unavailable</span>'; return; }
+ if(!st.configured){
+ action.innerHTML='<span class="badge">setup needed</span>';
+ s.textContent='Register an app at developer.withings.com, then set WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET via the secrets skill and reload.';
+ return;
+ }
+ if(!st.connected){
+ action.innerHTML='<a class="button primary" href="/auth/withings">Connect Withings</a>';
+ return;
+ }
+ action.innerHTML='<button class="button" id="withSync">Sync now</button>';
+ s.textContent = st.last_sync_at ? ('Last sync: '+new Date(st.last_sync_at).toLocaleString()) : 'Connected — never synced';
+ document.getElementById('withSync').addEventListener('click', async (e)=>{
+ e.target.disabled=true; s.textContent='Syncing…';
+ try{ const j=await (await fetch('/api/withings/sync',{method:'POST'})).json();
+ s.textContent=j.ok?('fetched '+j.fetched+', +'+j.inserted+' new readings'):('error: '+(j.error||'?')); }
+ catch(err){ s.textContent='error: '+err.message; } finally{ e.target.disabled=false; }
+ });
+})();
+</script>
+
<%- include('partials/footer') %>
← ba2e47d assets: RentCast home-value estimate wiring (one-key-from-wo
·
back to AbramsOS
·
chore: lint (node --check 21/21) + version bump v0.3.0 (sess fc3c624 →